@contractkit/plugin-typescript 0.35.0 → 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 +21 -19
- package/CHANGELOG.md +34 -0
- package/README.md +2 -2
- package/dist/codegen-operation.d.ts +1 -1
- package/dist/codegen-operation.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +160 -8
- 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.map +1 -1
- package/dist/server-framework.d.ts +8 -1
- package/dist/server-framework.d.ts.map +1 -1
- package/llms.txt +1 -1
- package/package.json +2 -2
- package/src/codegen-operation.ts +49 -5
- package/src/index.ts +1 -0
- package/src/server-framework-fastify.ts +128 -0
- package/src/server-framework-koa.ts +2 -0
- package/src/server-framework.ts +11 -1
- package/tests/codegen-operation-framework.test.ts +44 -0
- package/tests/codegen-operation.test.ts +45 -0
- package/tests/server-framework-fastify.test.ts +148 -0
- package/tests/server-framework.test.ts +1 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ServerFramework } from './server-framework.js';
|
|
2
|
+
/**
|
|
3
|
+
* ServerKit on Fastify: `ServerKitRouter()` collects routes the way a Koa app reads and mounts them
|
|
4
|
+
* as a Fastify plugin, handlers take `(request, reply)` where the request *is* the ServerKit
|
|
5
|
+
* context, and a response is sent by returning `reply.send(...)` rather than by assignment.
|
|
6
|
+
*/
|
|
7
|
+
export declare const FASTIFY_SERVER_FRAMEWORK: ServerFramework;
|
|
8
|
+
//# sourceMappingURL=server-framework-fastify.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server-framework-fastify.d.ts","sourceRoot":"","sources":["../src/server-framework-fastify.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAc7D;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,eA4GtC,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server-framework-koa.d.ts","sourceRoot":"","sources":["../src/server-framework-koa.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAW7D;;;GAGG;AACH,eAAO,MAAM,oBAAoB,EAAE,
|
|
1
|
+
{"version":3,"file":"server-framework-koa.d.ts","sourceRoot":"","sources":["../src/server-framework-koa.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAW7D;;;GAGG;AACH,eAAO,MAAM,oBAAoB,EAAE,eAkGlC,CAAC"}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* HTTP frameworks the server sub-generator can target. Adding a name here without adding an adapter
|
|
3
3
|
* to {@link SERVER_FRAMEWORKS} fails to compile, which is the point of keeping the two in step.
|
|
4
4
|
*/
|
|
5
|
-
export declare const SERVER_FRAMEWORK_NAMES: readonly ["koa"];
|
|
5
|
+
export declare const SERVER_FRAMEWORK_NAMES: readonly ["koa", "fastify"];
|
|
6
6
|
/** One of {@link SERVER_FRAMEWORK_NAMES}. */
|
|
7
7
|
export type ServerFrameworkName = (typeof SERVER_FRAMEWORK_NAMES)[number];
|
|
8
8
|
/** The framework assumed when a config names none. */
|
|
@@ -29,6 +29,13 @@ export interface ServerFramework {
|
|
|
29
29
|
routerDeclaration(routerName: string): string;
|
|
30
30
|
/** Placeholder syntax for one path parameter, given a name already mapped to a valid identifier. */
|
|
31
31
|
pathParam(identifier: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Identifiers the handler signature itself binds — `ctx`, or `request` and `reply`. A path
|
|
34
|
+
* parameter is destructured into the handler body, so one declared with the same name would
|
|
35
|
+
* shadow the handler's own parameter: a redeclaration under `tsc`, and a temporal-dead-zone
|
|
36
|
+
* `ReferenceError` at runtime. Codegen renames the local binding to avoid these.
|
|
37
|
+
*/
|
|
38
|
+
readonly handlerLocals: readonly string[];
|
|
32
39
|
/** Opening line of a handler, including its middleware and the handler function's parameters. */
|
|
33
40
|
routeOpen(routerName: string, method: string, path: string, middlewares: readonly string[]): string;
|
|
34
41
|
/** Lines that close a handler opened by {@link routeOpen}. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server-framework.d.ts","sourceRoot":"","sources":["../src/server-framework.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"server-framework.d.ts","sourceRoot":"","sources":["../src/server-framework.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,eAAO,MAAM,sBAAsB,6BAA8B,CAAC;AAElE,6CAA6C;AAC7C,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,sBAAsB,CAAC,CAAC,MAAM,CAAC,CAAC;AAE1E,sDAAsD;AACtD,eAAO,MAAM,6BAA6B,EAAE,mBAA2B,CAAC;AAExE;;;;;;;GAOG;AACH,MAAM,WAAW,eAAe;IAC5B,QAAQ,CAAC,IAAI,EAAE,mBAAmB,CAAC;IAEnC;;;;;;OAMG;IACH,OAAO,CAAC,IAAI,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,GAAG,MAAM,EAAE,CAAC;IAErD,+DAA+D;IAC/D,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC;IAE9C,oGAAoG;IACpG,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC;IAEtC;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,EAAE,SAAS,MAAM,EAAE,CAAC;IAE1C,iGAAiG;IACjG,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAAC;IAEpG,8DAA8D;IAC9D,UAAU,IAAI,MAAM,EAAE,CAAC;IAEvB,qFAAqF;IACrF,QAAQ,CAAC,UAAU,EAAE;QACjB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;QAC7B,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC;QACvC,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;KACnC,CAAC;IAEF,uDAAuD;IACvD,QAAQ,CAAC,OAAO,EAAE;QACd,MAAM,EAAE,MAAM,CAAC;QACf,KAAK,EAAE,MAAM,CAAC;QACd,OAAO,EAAE,MAAM,CAAC;QAChB,6DAA6D;QAC7D,UAAU,EAAE,MAAM,CAAC;QACnB;;;;WAIG;QACH,WAAW,EAAE,MAAM,CAAC;KACvB,CAAC;IAEF,mFAAmF;IACnF,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC;IAE1C,qDAAqD;IACrD,QAAQ,CAAC,QAAQ,EAAE;QACf,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;QAC7B;;;WAGG;QACH,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC;QAChD,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;QAC3B;;;;WAIG;QACH,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAAC;QAC7C,6FAA6F;QAC7F,OAAO,IAAI,MAAM,EAAE,CAAC;KACvB,CAAC;IAEF,+FAA+F;IAC/F,SAAS,CAAC,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAC;CAChD;AAED;;;GAGG;AACH,eAAO,MAAM,iBAAiB,EAAE,QAAQ,CAAC,MAAM,CAAC,mBAAmB,EAAE,eAAe,CAAC,CAGpF,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,eAAe,CAShF"}
|
package/llms.txt
CHANGED
|
@@ -56,7 +56,7 @@ Path templates accept `{filename}`, `{dir}`, `{area}`, and — in `sdk.output.sd
|
|
|
56
56
|
|
|
57
57
|
| Key | Emits |
|
|
58
58
|
| --- | --- |
|
|
59
|
-
| `server` | Server routers from `operation` declarations, plus the type or Zod files they import. `framework` selects the HTTP framework
|
|
59
|
+
| `server` | Server routers from `operation` declarations, plus the type or Zod files they import. `framework` selects the HTTP framework: `koa` or `fastify` |
|
|
60
60
|
| `sdk` | The SDK class, per-area operation clients, and their types |
|
|
61
61
|
| `zod` | Standalone Zod schemas, independent of `server` and `sdk` |
|
|
62
62
|
| `types` | Standalone plain TypeScript types, independent of `server` and `sdk` |
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contractkit/plugin-typescript",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa routers, Zod schemas, plain types)",
|
|
3
|
+
"version": "0.36.0",
|
|
4
|
+
"description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa or Fastify routers, Zod schemas, plain types)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Marooned Software",
|
package/src/codegen-operation.ts
CHANGED
|
@@ -27,6 +27,34 @@ import { KOA_SERVER_FRAMEWORK } from './server-framework-koa.js';
|
|
|
27
27
|
/** Which request-side object a validation block reads from. Names the variable the block declares. */
|
|
28
28
|
export type ParamKind = 'params' | 'query' | 'headers';
|
|
29
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
|
+
}
|
|
57
|
+
|
|
30
58
|
// ─── Content-type helpers ──────────────────────────────────────────────────
|
|
31
59
|
|
|
32
60
|
/**
|
|
@@ -362,7 +390,10 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
362
390
|
lines.push(framework.routeOpen(deriveRouterName(file), method, path, middlewares));
|
|
363
391
|
|
|
364
392
|
// Params / query / headers validation (request-side — use Input variants)
|
|
365
|
-
|
|
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));
|
|
366
397
|
lines.push(...generateParamValidation(op.query, 'query', framework.request.query, op.queryMode ?? 'strict', '', modelsWithInput));
|
|
367
398
|
lines.push(...generateParamValidation(op.headers, 'headers', framework.request.headers, op.headersMode ?? 'strip', '', modelsWithInput));
|
|
368
399
|
|
|
@@ -405,7 +436,7 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
405
436
|
// responsible for producing; the rest are documentation, or the thrown-error path.
|
|
406
437
|
const emitted = emittedResponses(op);
|
|
407
438
|
const serviceParts = inferService(op, route, file);
|
|
408
|
-
const call = `await service.${serviceParts.methodName}(${buildArgs(route, op)})`;
|
|
439
|
+
const call = `await service.${serviceParts.methodName}(${buildArgs(route, op, pathBindings)})`;
|
|
409
440
|
|
|
410
441
|
if (emitted.length > 1) {
|
|
411
442
|
lines.push(...generateMultiStatusResult(emitted, serviceParts.className, call, options));
|
|
@@ -654,12 +685,14 @@ function inferMethodName(method: string, path: string): string {
|
|
|
654
685
|
*
|
|
655
686
|
* @returns The rendered argument list, or an empty string when the method takes no arguments.
|
|
656
687
|
*/
|
|
657
|
-
export function buildArgs(route: OpRouteNode, op: OpOperationNode): string {
|
|
688
|
+
export function buildArgs(route: OpRouteNode, op: OpOperationNode, bindings?: Map<string, string>): string {
|
|
658
689
|
const args: string[] = [];
|
|
659
690
|
// Path params: spread individually (inline) or pass 'params' object (type-ref/ContractTypeNode)
|
|
660
691
|
if (route.params) {
|
|
661
692
|
if (route.params.kind === 'params') {
|
|
662
|
-
|
|
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)));
|
|
663
696
|
} else {
|
|
664
697
|
args.push('params');
|
|
665
698
|
}
|
|
@@ -821,6 +854,7 @@ function generateParamValidation(
|
|
|
821
854
|
mode: ObjectMode,
|
|
822
855
|
suffix = '',
|
|
823
856
|
modelsWithInput?: Set<string>,
|
|
857
|
+
bindings?: Map<string, string>,
|
|
824
858
|
): string[] {
|
|
825
859
|
if (!source) return [];
|
|
826
860
|
const lines: string[] = [];
|
|
@@ -837,7 +871,17 @@ function generateParamValidation(
|
|
|
837
871
|
// Inline param declarations — wrap with the appropriate z.*Object constructor
|
|
838
872
|
if (source.nodes.length > 0) {
|
|
839
873
|
const bind = (name: string) => (isPathParams ? toIdentifier(name) : name);
|
|
840
|
-
|
|
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;
|
|
841
885
|
lines.push(` const ${lhs} = await parseAndValidate(`);
|
|
842
886
|
lines.push(` ${sourceExpr},`);
|
|
843
887
|
lines.push(` ${modeToWrapper(mode)}({`);
|
package/src/index.ts
CHANGED
|
@@ -55,6 +55,7 @@ export {
|
|
|
55
55
|
type ServerFrameworkName,
|
|
56
56
|
} from './server-framework.js';
|
|
57
57
|
export { KOA_SERVER_FRAMEWORK } from './server-framework-koa.js';
|
|
58
|
+
export { FASTIFY_SERVER_FRAMEWORK } from './server-framework-fastify.js';
|
|
58
59
|
|
|
59
60
|
/** Taint set for the SDK's bigint response reviver. */
|
|
60
61
|
const BIGINT_SCALARS: ReadonlySet<ScalarTypeNode['name']> = new Set(['bigint']);
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { ServerFramework } from './server-framework.js';
|
|
2
|
+
|
|
3
|
+
/** Module the Fastify flavour of ServerKit publishes its router and route middleware from. */
|
|
4
|
+
const FASTIFY_RUNTIME_MODULE = '@maroonedsoftware/fastify';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Symbols importable from {@link FASTIFY_RUNTIME_MODULE}. Every one is a name the adapter itself
|
|
8
|
+
* emits, so none can collide with a service class or router name derived from a contract.
|
|
9
|
+
*
|
|
10
|
+
* `requestMediaType` is here because Fastify has no accessor that strips the parameters off
|
|
11
|
+
* `Content-Type`, and it is only referenced when an operation declares several request MIME types.
|
|
12
|
+
*/
|
|
13
|
+
const FASTIFY_RUNTIME_SYMBOLS = ['ServerKitRouter', 'bodyParserMiddleware', 'requirePolicy', 'requireSignature', 'requestMediaType'] as const;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* ServerKit on Fastify: `ServerKitRouter()` collects routes the way a Koa app reads and mounts them
|
|
17
|
+
* as a Fastify plugin, handlers take `(request, reply)` where the request *is* the ServerKit
|
|
18
|
+
* context, and a response is sent by returning `reply.send(...)` rather than by assignment.
|
|
19
|
+
*/
|
|
20
|
+
export const FASTIFY_SERVER_FRAMEWORK: ServerFramework = {
|
|
21
|
+
name: 'fastify',
|
|
22
|
+
|
|
23
|
+
imports(uses) {
|
|
24
|
+
const symbols = FASTIFY_RUNTIME_SYMBOLS.filter(uses);
|
|
25
|
+
return symbols.length > 0 ? [`import { ${symbols.join(', ')} } from '${FASTIFY_RUNTIME_MODULE}';`] : [];
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
routerDeclaration(routerName) {
|
|
29
|
+
return `export const ${routerName} = ServerKitRouter();`;
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
pathParam(identifier) {
|
|
33
|
+
return `:${identifier}`;
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
handlerLocals: ['request', 'reply'],
|
|
37
|
+
|
|
38
|
+
routeOpen(routerName, method, path, middlewares) {
|
|
39
|
+
const middlewareStr = middlewares.length > 0 ? `, ${middlewares.join(', ')},` : ',';
|
|
40
|
+
return `${routerName}.${method}('${path}'${middlewareStr} async (request, reply) => {`;
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
routeClose() {
|
|
44
|
+
return ['});'];
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
middleware: {
|
|
48
|
+
policy(args) {
|
|
49
|
+
return `requirePolicy(${args})`;
|
|
50
|
+
},
|
|
51
|
+
bodyParser(tokensExpr) {
|
|
52
|
+
return `bodyParserMiddleware([${tokensExpr}])`;
|
|
53
|
+
},
|
|
54
|
+
signature(args) {
|
|
55
|
+
return `requireSignature(${args})`;
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
request: {
|
|
60
|
+
params: 'request.params',
|
|
61
|
+
query: 'request.query',
|
|
62
|
+
headers: 'request.headers',
|
|
63
|
+
// ServerKit parses lazily per route, so Fastify's own `request.body` is never populated.
|
|
64
|
+
parsedBody: 'request.parsedBody',
|
|
65
|
+
// A call, not a property: the raw header carries `; charset=utf-8`, which would match none of
|
|
66
|
+
// the declared MIME literals the generated switch compares against.
|
|
67
|
+
contentType: 'requestMediaType(request)',
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
resolveService(className) {
|
|
71
|
+
return `request.container.get(${className})`;
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
response: {
|
|
75
|
+
status(expr) {
|
|
76
|
+
return `reply.status(${expr});`;
|
|
77
|
+
},
|
|
78
|
+
header(name, valueExpr) {
|
|
79
|
+
return `reply.header('${name}', ${valueExpr});`;
|
|
80
|
+
},
|
|
81
|
+
type(expr) {
|
|
82
|
+
return `reply.type(${expr});`;
|
|
83
|
+
},
|
|
84
|
+
send(bodyExpr) {
|
|
85
|
+
// Unlike Koa, a bodyless response still needs a statement: a handler that neither returns
|
|
86
|
+
// a body nor calls `send` leaves the request hanging.
|
|
87
|
+
return bodyExpr === undefined ? ['return reply.send();'] : [`return reply.send(${bodyExpr});`];
|
|
88
|
+
},
|
|
89
|
+
caseEnd() {
|
|
90
|
+
// Every status case has already returned, so a `break` here would be unreachable code.
|
|
91
|
+
return [];
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
mcpRouter({ path }) {
|
|
96
|
+
return `import { type ServerKitRouterType, bodyParserMiddleware, requireSignature, requestHeader } from '${FASTIFY_RUNTIME_MODULE}';
|
|
97
|
+
import { McpDispatcher, createMcpRequestContext, MCP_AUTH_POLICY } from '@maroonedsoftware/mcp';
|
|
98
|
+
|
|
99
|
+
/** Mount the MCP endpoint onto a ServerKit router. Bind \`registerMcpTools\` to the \`McpToolHandlerMap\` token. */
|
|
100
|
+
export function mountMcp(router: ServerKitRouterType): void {
|
|
101
|
+
router.post('${path}', bodyParserMiddleware(['json']), requireSignature('mcp', { policy: MCP_AUTH_POLICY }), async (request, reply) => {
|
|
102
|
+
const dispatcher = request.container.get(McpDispatcher);
|
|
103
|
+
const context = createMcpRequestContext({ requestId: request.requestId, logger: request.logger });
|
|
104
|
+
if (dispatcher.sessionMode === 'stateful') {
|
|
105
|
+
// Fastify's equivalent of Koa's \`ctx.respond = false\`: the dispatcher writes the raw
|
|
106
|
+
// response itself, and the request scope is disposed on the raw socket close instead.
|
|
107
|
+
reply.hijack();
|
|
108
|
+
await dispatcher.dispatchStateful(
|
|
109
|
+
{
|
|
110
|
+
req: request.raw,
|
|
111
|
+
res: reply.raw,
|
|
112
|
+
body: request.parsedBody,
|
|
113
|
+
// \`requestHeader\` returns '' for an absent header; the session id is optional.
|
|
114
|
+
sessionId: requestHeader(request, 'mcp-session-id') || undefined,
|
|
115
|
+
},
|
|
116
|
+
context,
|
|
117
|
+
);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const response = await dispatcher.dispatch(JSON.parse(String(request.rawBody)), context);
|
|
121
|
+
if (response) return reply.send(response);
|
|
122
|
+
reply.status(202); // a notification — nothing to return
|
|
123
|
+
return reply.send();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
`;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
@@ -29,6 +29,8 @@ export const KOA_SERVER_FRAMEWORK: ServerFramework = {
|
|
|
29
29
|
return `:${identifier}`;
|
|
30
30
|
},
|
|
31
31
|
|
|
32
|
+
handlerLocals: ['ctx'],
|
|
33
|
+
|
|
32
34
|
routeOpen(routerName, method, path, middlewares) {
|
|
33
35
|
const middlewareStr = middlewares.length > 0 ? `, ${middlewares.join(', ')},` : ',';
|
|
34
36
|
return `${routerName}.${method}('${path}'${middlewareStr} async ctx => {`;
|
package/src/server-framework.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { KOA_SERVER_FRAMEWORK } from './server-framework-koa.js';
|
|
2
|
+
import { FASTIFY_SERVER_FRAMEWORK } from './server-framework-fastify.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* HTTP frameworks the server sub-generator can target. Adding a name here without adding an adapter
|
|
5
6
|
* to {@link SERVER_FRAMEWORKS} fails to compile, which is the point of keeping the two in step.
|
|
6
7
|
*/
|
|
7
|
-
export const SERVER_FRAMEWORK_NAMES = ['koa'] as const;
|
|
8
|
+
export const SERVER_FRAMEWORK_NAMES = ['koa', 'fastify'] as const;
|
|
8
9
|
|
|
9
10
|
/** One of {@link SERVER_FRAMEWORK_NAMES}. */
|
|
10
11
|
export type ServerFrameworkName = (typeof SERVER_FRAMEWORK_NAMES)[number];
|
|
@@ -38,6 +39,14 @@ export interface ServerFramework {
|
|
|
38
39
|
/** Placeholder syntax for one path parameter, given a name already mapped to a valid identifier. */
|
|
39
40
|
pathParam(identifier: string): string;
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Identifiers the handler signature itself binds — `ctx`, or `request` and `reply`. A path
|
|
44
|
+
* parameter is destructured into the handler body, so one declared with the same name would
|
|
45
|
+
* shadow the handler's own parameter: a redeclaration under `tsc`, and a temporal-dead-zone
|
|
46
|
+
* `ReferenceError` at runtime. Codegen renames the local binding to avoid these.
|
|
47
|
+
*/
|
|
48
|
+
readonly handlerLocals: readonly string[];
|
|
49
|
+
|
|
41
50
|
/** Opening line of a handler, including its middleware and the handler function's parameters. */
|
|
42
51
|
routeOpen(routerName: string, method: string, path: string, middlewares: readonly string[]): string;
|
|
43
52
|
|
|
@@ -98,6 +107,7 @@ export interface ServerFramework {
|
|
|
98
107
|
*/
|
|
99
108
|
export const SERVER_FRAMEWORKS: Readonly<Record<ServerFrameworkName, ServerFramework>> = {
|
|
100
109
|
koa: KOA_SERVER_FRAMEWORK,
|
|
110
|
+
fastify: FASTIFY_SERVER_FRAMEWORK,
|
|
101
111
|
};
|
|
102
112
|
|
|
103
113
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { generateOp } from '../src/codegen-operation.js';
|
|
3
3
|
import type { ServerFramework } from '../src/server-framework.js';
|
|
4
|
+
import { FASTIFY_SERVER_FRAMEWORK } from '../src/server-framework-fastify.js';
|
|
4
5
|
import { scalarType, refType, opParam, opRequest, opMultiRequest, opResponse, opResponseMulti, opOperation, opRoute, opRoot } from './helpers.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -15,6 +16,7 @@ const STUB: ServerFramework = {
|
|
|
15
16
|
imports: uses => (uses('StubRouter') ? ["import { StubRouter } from '@stub/http';"] : []),
|
|
16
17
|
routerDeclaration: routerName => `export const ${routerName} = StubRouter();`,
|
|
17
18
|
pathParam: identifier => `<${identifier}>`,
|
|
19
|
+
handlerLocals: ['rq', 'rs'],
|
|
18
20
|
routeOpen: (routerName, method, path, middlewares) => `${routerName}.route('${method}', '${path}', [${middlewares.join(', ')}], async (rq, rs) => {`,
|
|
19
21
|
routeClose: () => ['}, END);'],
|
|
20
22
|
middleware: {
|
|
@@ -138,3 +140,45 @@ describe('generateOp — framework seam', () => {
|
|
|
138
140
|
expect(statusSwitch).not.toContain('break;');
|
|
139
141
|
});
|
|
140
142
|
});
|
|
143
|
+
|
|
144
|
+
describe('generateOp — Fastify', () => {
|
|
145
|
+
const output = generateOp(everyBranchRoot(), { framework: FASTIFY_SERVER_FRAMEWORK });
|
|
146
|
+
|
|
147
|
+
it('opens each handler with the Fastify signature', () => {
|
|
148
|
+
expect(output).toContain("UsersRouter.post('/payments/:paymentId', requirePolicy(), bodyParserMiddleware(['json']), requireSignature('stripe'), async (request, reply) => {");
|
|
149
|
+
expect(output).not.toMatch(/\bctx\b/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('imports the runtime helper only because the multi-MIME switch uses it', () => {
|
|
153
|
+
expect(output).toContain(
|
|
154
|
+
"import { ServerKitRouter, bodyParserMiddleware, requirePolicy, requireSignature, requestMediaType } from '@maroonedsoftware/fastify';",
|
|
155
|
+
);
|
|
156
|
+
expect(output).toContain('switch (requestMediaType(request)) {');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('reads the request through the request object', () => {
|
|
160
|
+
expect(output).toContain('request.params');
|
|
161
|
+
expect(output).toContain('request.query');
|
|
162
|
+
expect(output).toContain('request.headers');
|
|
163
|
+
expect(output).toContain('await parseAndValidate(request.parsedBody,');
|
|
164
|
+
expect(output).toContain('request.container.get(UsersService)');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('writes the response through reply, returning the send', () => {
|
|
168
|
+
expect(output).toContain('reply.status(201);');
|
|
169
|
+
expect(output).toContain("reply.type('application/json');");
|
|
170
|
+
expect(output).toContain('return reply.send(result.body);');
|
|
171
|
+
expect(output).toContain('reply.header(\'etag\', String(result.headers["etag"]));');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('sends explicitly for a bodyless response, where Koa writes nothing', () => {
|
|
175
|
+
expect(output).toContain('reply.status(204);');
|
|
176
|
+
expect(output).toContain('return reply.send();');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('leaves no break in the status switch, since every case returns', () => {
|
|
180
|
+
const statusSwitch = output.slice(output.indexOf('switch (result.status) {'));
|
|
181
|
+
expect(statusSwitch).toContain('case 202:');
|
|
182
|
+
expect(statusSwitch).not.toContain('break;');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -394,6 +394,51 @@ describe('generateOperation', () => {
|
|
|
394
394
|
|
|
395
395
|
// ─── Query validation ────────────────────────────────────────
|
|
396
396
|
|
|
397
|
+
describe('path params that would shadow a handler local', () => {
|
|
398
|
+
it('renames the binding but keeps the placeholder and the schema key', () => {
|
|
399
|
+
// `ctx` is the Koa handler's own parameter. Destructuring one under that name redeclares
|
|
400
|
+
// it: a tsc error, and a temporal-dead-zone ReferenceError at runtime.
|
|
401
|
+
const root = opRoot([opRoute('/threads/{ctx}', [opOperation('get')], [opParam('ctx', scalarType('uuid'))])]);
|
|
402
|
+
const output = generateOp(root);
|
|
403
|
+
expect(output).toContain("get('/threads/:ctx'");
|
|
404
|
+
expect(output).toContain('ctx: z.uuid()');
|
|
405
|
+
expect(output).toContain('const { ctx: ctx_ } = await parseAndValidate(');
|
|
406
|
+
expect(output).not.toContain('const { ctx } =');
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('passes the renamed identifier to the service', () => {
|
|
410
|
+
const root = opRoot([opRoute('/threads/{ctx}', [opOperation('get')], [opParam('ctx', scalarType('uuid'))])]);
|
|
411
|
+
expect(generateOp(root)).toContain('await service.getById(ctx_)');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('renames a param that would shadow one of the generator\'s own locals', () => {
|
|
415
|
+
// `body` is the variable the request-body block declares.
|
|
416
|
+
const root = opRoot([
|
|
417
|
+
opRoute('/posts/{body}', [opOperation('post', { request: opRequest('CreatePost') })], [opParam('body', scalarType('uuid'))]),
|
|
418
|
+
]);
|
|
419
|
+
const output = generateOp(root);
|
|
420
|
+
expect(output).toContain('const { body: body_ } = await parseAndValidate(');
|
|
421
|
+
expect(output).toContain('const body = await parseAndValidate(');
|
|
422
|
+
expect(output).toContain('await service.create(body_, body)');
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('leaves a param alone when nothing collides', () => {
|
|
426
|
+
const root = opRoot([opRoute('/users/{userId}', [opOperation('get')], [opParam('userId', scalarType('uuid'))])]);
|
|
427
|
+
const output = generateOp(root);
|
|
428
|
+
expect(output).toContain('const { userId } = await parseAndValidate(');
|
|
429
|
+
expect(output).toContain('await service.getById(userId)');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('keeps two params distinct when a rename would collapse them onto one name', () => {
|
|
433
|
+
const root = opRoot([
|
|
434
|
+
opRoute('/x/{ctx}/{ctx_}', [opOperation('get')], [opParam('ctx', scalarType('uuid')), opParam('ctx_', scalarType('uuid'))]),
|
|
435
|
+
]);
|
|
436
|
+
const output = generateOp(root);
|
|
437
|
+
expect(output).toContain('const { ctx: ctx_, ctx_: ctx__ } = await parseAndValidate(');
|
|
438
|
+
expect(output).toContain('await service.getById(ctx_, ctx__)');
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
397
442
|
describe('query validation', () => {
|
|
398
443
|
it('generates query validation block', () => {
|
|
399
444
|
const root = opRoot([
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { FASTIFY_SERVER_FRAMEWORK as fastify } from '../src/server-framework-fastify.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pinned method by method, the way the Koa adapter is. These strings are the contract with
|
|
6
|
+
* `@maroonedsoftware/fastify`, so a change to any of them should fail here, naming the piece,
|
|
7
|
+
* rather than only as a snapshot diff in another package.
|
|
8
|
+
*/
|
|
9
|
+
describe('FASTIFY_SERVER_FRAMEWORK', () => {
|
|
10
|
+
it('is named fastify', () => {
|
|
11
|
+
expect(fastify.name).toBe('fastify');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('imports', () => {
|
|
15
|
+
it('emits one line naming every symbol the body uses', () => {
|
|
16
|
+
expect(fastify.imports(() => true)).toEqual([
|
|
17
|
+
"import { ServerKitRouter, bodyParserMiddleware, requirePolicy, requireSignature, requestMediaType } from '@maroonedsoftware/fastify';",
|
|
18
|
+
]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('narrows to the symbols the body actually references', () => {
|
|
22
|
+
expect(fastify.imports(s => s === 'requestMediaType')).toEqual(["import { requestMediaType } from '@maroonedsoftware/fastify';"]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('emits nothing when the body references none of them', () => {
|
|
26
|
+
expect(fastify.imports(() => false)).toEqual([]);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('declares the router', () => {
|
|
31
|
+
expect(fastify.routerDeclaration('UsersRouter')).toBe('export const UsersRouter = ServerKitRouter();');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('renders a path parameter with a colon', () => {
|
|
35
|
+
expect(fastify.pathParam('userId')).toBe(':userId');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('reserves the two identifiers its handler signature binds', () => {
|
|
39
|
+
expect(fastify.handlerLocals).toEqual(['request', 'reply']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('routeOpen', () => {
|
|
43
|
+
it('omits the middleware list when there is none', () => {
|
|
44
|
+
expect(fastify.routeOpen('UsersRouter', 'get', '/users', [])).toBe("UsersRouter.get('/users', async (request, reply) => {");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('places the middleware between the path and the handler', () => {
|
|
48
|
+
expect(fastify.routeOpen('UsersRouter', 'post', '/users', ['requirePolicy()', "bodyParserMiddleware(['json'])"])).toBe(
|
|
49
|
+
"UsersRouter.post('/users', requirePolicy(), bodyParserMiddleware(['json']), async (request, reply) => {",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('closes a route', () => {
|
|
55
|
+
expect(fastify.routeClose()).toEqual(['});']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('renders the route middleware factories, which take the same arguments as Koa\'s', () => {
|
|
59
|
+
expect(fastify.middleware.policy('')).toBe('requirePolicy()');
|
|
60
|
+
expect(fastify.middleware.policy("{ policy: 'admin' }")).toBe("requirePolicy({ policy: 'admin' })");
|
|
61
|
+
expect(fastify.middleware.bodyParser("'json', 'multipart'")).toBe("bodyParserMiddleware(['json', 'multipart'])");
|
|
62
|
+
expect(fastify.middleware.signature("'slack'")).toBe("requireSignature('slack')");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('reads the request off the request object, which is the context', () => {
|
|
66
|
+
expect(fastify.request).toEqual({
|
|
67
|
+
params: 'request.params',
|
|
68
|
+
query: 'request.query',
|
|
69
|
+
headers: 'request.headers',
|
|
70
|
+
parsedBody: 'request.parsedBody',
|
|
71
|
+
// A call rather than a property: the raw header carries `; charset=…`, which matches no
|
|
72
|
+
// declared MIME literal.
|
|
73
|
+
contentType: 'requestMediaType(request)',
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('resolves a service from the request container', () => {
|
|
78
|
+
expect(fastify.resolveService('PaymentService')).toBe('request.container.get(PaymentService)');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('response', () => {
|
|
82
|
+
it('sets the status through reply', () => {
|
|
83
|
+
expect(fastify.response.status('200')).toBe('reply.status(200);');
|
|
84
|
+
expect(fastify.response.status('result.status')).toBe('reply.status(result.status);');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('sets a header as a single statement, so the optional guard can wrap it', () => {
|
|
88
|
+
expect(fastify.response.header('x-request-id', 'String(result.headers["xRequestId"])')).toBe(
|
|
89
|
+
'reply.header(\'x-request-id\', String(result.headers["xRequestId"]));',
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('sets the content type', () => {
|
|
94
|
+
expect(fastify.response.type("'application/json'")).toBe("reply.type('application/json');");
|
|
95
|
+
expect(fastify.response.type('result.contentType')).toBe('reply.type(result.contentType);');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns the send, including for a response with no body', () => {
|
|
99
|
+
expect(fastify.response.send('result')).toEqual(['return reply.send(result);']);
|
|
100
|
+
// Unlike Koa: a handler that neither returns a body nor calls send leaves the request hanging.
|
|
101
|
+
expect(fastify.response.send(undefined)).toEqual(['return reply.send();']);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('closes a status case with nothing, since the case already returned', () => {
|
|
105
|
+
expect(fastify.response.caseEnd()).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('mcpRouter', () => {
|
|
110
|
+
const out = fastify.mcpRouter({ path: '/mcp' });
|
|
111
|
+
|
|
112
|
+
it('mounts the dispatcher at the given path', () => {
|
|
113
|
+
expect(fastify.mcpRouter({ path: '/tools' })).toContain("router.post('/tools',");
|
|
114
|
+
expect(out).toContain("from '@maroonedsoftware/fastify'");
|
|
115
|
+
expect(out).toContain('const dispatcher = request.container.get(McpDispatcher);');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('names the exported router type rather than inferring it', () => {
|
|
119
|
+
expect(out).toContain('export function mountMcp(router: ServerKitRouterType): void {');
|
|
120
|
+
// And therefore never imports the factory value, which `noUnusedLocals` would reject.
|
|
121
|
+
expect(out).toContain("import { type ServerKitRouterType, bodyParserMiddleware, requireSignature, requestHeader } from '@maroonedsoftware/fastify';");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('hijacks the reply for a stateful session, which is Fastify\'s ctx.respond = false', () => {
|
|
125
|
+
expect(out).toContain('reply.hijack();');
|
|
126
|
+
expect(out).toContain('req: request.raw,');
|
|
127
|
+
expect(out).toContain('res: reply.raw,');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('reads the parsed body, not Fastify\'s own request.body', () => {
|
|
131
|
+
expect(out).toContain('body: request.parsedBody,');
|
|
132
|
+
expect(out).not.toContain('request.body,');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('passes undefined rather than the empty string for an absent session id', () => {
|
|
136
|
+
expect(out).toContain("sessionId: requestHeader(request, 'mcp-session-id') || undefined,");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('answers a notification with a bodyless 202', () => {
|
|
140
|
+
expect(out).toContain('reply.status(202);');
|
|
141
|
+
expect(out).toContain('return reply.send();');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('escapes the backticks in its own doc comment rather than closing the template', () => {
|
|
145
|
+
expect(out).toContain('Bind `registerMcpTools` to the `McpToolHandlerMap` token.');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -9,6 +9,7 @@ describe('resolveServerFramework', () => {
|
|
|
9
9
|
|
|
10
10
|
it('resolves a supported name', () => {
|
|
11
11
|
expect(resolveServerFramework('koa').name).toBe('koa');
|
|
12
|
+
expect(resolveServerFramework('fastify').name).toBe('fastify');
|
|
12
13
|
});
|
|
13
14
|
|
|
14
15
|
it('rejects an unsupported name, naming what is supported', () => {
|