@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
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { ServerFramework } from './server-framework.js';
|
|
2
|
+
|
|
3
|
+
/** Module the Koa flavour of ServerKit publishes its router and route middleware from. */
|
|
4
|
+
const KOA_RUNTIME_MODULE = '@maroonedsoftware/koa';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Symbols importable from {@link KOA_RUNTIME_MODULE}. Every one is a name the adapter itself emits,
|
|
8
|
+
* so none can collide with a service class or router name derived from a contract.
|
|
9
|
+
*/
|
|
10
|
+
const KOA_RUNTIME_SYMBOLS = ['ServerKitRouter', 'bodyParserMiddleware', 'requirePolicy', 'requireSignature'] as const;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* ServerKit on Koa: the router is a `@koa/router` instance, handlers take a single `ctx`, and a
|
|
14
|
+
* response is written by assigning to `ctx.status` / `ctx.type` / `ctx.body` rather than returned.
|
|
15
|
+
*/
|
|
16
|
+
export const KOA_SERVER_FRAMEWORK: ServerFramework = {
|
|
17
|
+
name: 'koa',
|
|
18
|
+
|
|
19
|
+
imports(uses) {
|
|
20
|
+
const symbols = KOA_RUNTIME_SYMBOLS.filter(uses);
|
|
21
|
+
return symbols.length > 0 ? [`import { ${symbols.join(', ')} } from '${KOA_RUNTIME_MODULE}';`] : [];
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
routerDeclaration(routerName) {
|
|
25
|
+
return `export const ${routerName} = ServerKitRouter();`;
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
pathParam(identifier) {
|
|
29
|
+
return `:${identifier}`;
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
handlerLocals: ['ctx'],
|
|
33
|
+
|
|
34
|
+
routeOpen(routerName, method, path, middlewares) {
|
|
35
|
+
const middlewareStr = middlewares.length > 0 ? `, ${middlewares.join(', ')},` : ',';
|
|
36
|
+
return `${routerName}.${method}('${path}'${middlewareStr} async ctx => {`;
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
routeClose() {
|
|
40
|
+
return ['});'];
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
middleware: {
|
|
44
|
+
policy(args) {
|
|
45
|
+
return `requirePolicy(${args})`;
|
|
46
|
+
},
|
|
47
|
+
bodyParser(tokensExpr) {
|
|
48
|
+
return `bodyParserMiddleware([${tokensExpr}])`;
|
|
49
|
+
},
|
|
50
|
+
signature(args) {
|
|
51
|
+
return `requireSignature(${args})`;
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
request: {
|
|
56
|
+
params: 'ctx.params',
|
|
57
|
+
query: 'ctx.query',
|
|
58
|
+
headers: 'ctx.headers',
|
|
59
|
+
// Not `ctx.request.body`: the ServerKit body parser drains the stream and writes its result
|
|
60
|
+
// here, and in Koa `ctx.body` is the *response* body.
|
|
61
|
+
parsedBody: 'ctx.parsedBody',
|
|
62
|
+
// Koa strips the parameters off `Content-Type` for this accessor already.
|
|
63
|
+
contentType: 'ctx.request.type',
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
resolveService(className) {
|
|
67
|
+
return `ctx.container.get(${className})`;
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
response: {
|
|
71
|
+
status(expr) {
|
|
72
|
+
return `ctx.status = ${expr};`;
|
|
73
|
+
},
|
|
74
|
+
header(name, valueExpr) {
|
|
75
|
+
return `ctx.set('${name}', ${valueExpr});`;
|
|
76
|
+
},
|
|
77
|
+
type(expr) {
|
|
78
|
+
return `ctx.type = ${expr};`;
|
|
79
|
+
},
|
|
80
|
+
send(bodyExpr) {
|
|
81
|
+
// A bodyless response needs no statement at all: Koa sends whatever `ctx.status` and the
|
|
82
|
+
// headers say once the handler resolves.
|
|
83
|
+
return bodyExpr === undefined ? [] : [`ctx.body = ${bodyExpr};`];
|
|
84
|
+
},
|
|
85
|
+
caseEnd() {
|
|
86
|
+
return ['break;'];
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
mcpRouter({ path }) {
|
|
91
|
+
return `import { ServerKitRouter, bodyParserMiddleware, requireSignature } from '${KOA_RUNTIME_MODULE}';
|
|
92
|
+
import { McpDispatcher, createMcpRequestContext, MCP_AUTH_POLICY } from '@maroonedsoftware/mcp';
|
|
93
|
+
|
|
94
|
+
/** Mount the MCP endpoint onto a ServerKit router. Bind \`registerMcpTools\` to the \`McpToolHandlerMap\` token. */
|
|
95
|
+
export function mountMcp(router: ReturnType<typeof ServerKitRouter>): void {
|
|
96
|
+
router.post('${path}', bodyParserMiddleware(['json']), requireSignature('mcp', { policy: MCP_AUTH_POLICY }), async (ctx) => {
|
|
97
|
+
const dispatcher = ctx.container.get(McpDispatcher);
|
|
98
|
+
const context = createMcpRequestContext({ requestId: ctx.requestId, logger: ctx.logger });
|
|
99
|
+
if (dispatcher.sessionMode === 'stateful') {
|
|
100
|
+
ctx.respond = false;
|
|
101
|
+
await dispatcher.dispatchStateful(
|
|
102
|
+
{ req: ctx.req, res: ctx.res, body: ctx.parsedBody, sessionId: ctx.get('mcp-session-id') },
|
|
103
|
+
context,
|
|
104
|
+
);
|
|
105
|
+
} else {
|
|
106
|
+
const response = await dispatcher.dispatch(JSON.parse(String(ctx.rawBody)), context);
|
|
107
|
+
if (response) ctx.body = response;
|
|
108
|
+
else ctx.status = 202; // a notification — nothing to return
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
`;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { KOA_SERVER_FRAMEWORK } from './server-framework-koa.js';
|
|
2
|
+
import { FASTIFY_SERVER_FRAMEWORK } from './server-framework-fastify.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* HTTP frameworks the server sub-generator can target. Adding a name here without adding an adapter
|
|
6
|
+
* to {@link SERVER_FRAMEWORKS} fails to compile, which is the point of keeping the two in step.
|
|
7
|
+
*/
|
|
8
|
+
export const SERVER_FRAMEWORK_NAMES = ['koa', 'fastify'] as const;
|
|
9
|
+
|
|
10
|
+
/** One of {@link SERVER_FRAMEWORK_NAMES}. */
|
|
11
|
+
export type ServerFrameworkName = (typeof SERVER_FRAMEWORK_NAMES)[number];
|
|
12
|
+
|
|
13
|
+
/** The framework assumed when a config names none. */
|
|
14
|
+
export const DEFAULT_SERVER_FRAMEWORK_NAME: ServerFrameworkName = 'koa';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Every framework-specific string the router and MCP router generators emit.
|
|
18
|
+
*
|
|
19
|
+
* Granularity is one statement (or one fragment) per method, so the shared codegen keeps ownership
|
|
20
|
+
* of control flow — which branches exist, what order they run in, and which values reach them — and
|
|
21
|
+
* an adapter only decides how a given step is spelled. Anything an adapter cannot express as a
|
|
22
|
+
* statement, such as ending a response, is returned as a list of lines so it can also be empty.
|
|
23
|
+
*/
|
|
24
|
+
export interface ServerFramework {
|
|
25
|
+
readonly name: ServerFrameworkName;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Import lines for the framework runtime, already filtered down to what the generated body uses.
|
|
29
|
+
*
|
|
30
|
+
* The adapter applies `uses` itself rather than declaring a symbol list, because a framework may
|
|
31
|
+
* need more than one import line, and because only names the adapter chooses ever go through the
|
|
32
|
+
* word-boundary probe — a handler-local identifier can never be mistaken for an import.
|
|
33
|
+
*/
|
|
34
|
+
imports(uses: (symbol: string) => boolean): string[];
|
|
35
|
+
|
|
36
|
+
/** The module-level router value every handler attaches to. */
|
|
37
|
+
routerDeclaration(routerName: string): string;
|
|
38
|
+
|
|
39
|
+
/** Placeholder syntax for one path parameter, given a name already mapped to a valid identifier. */
|
|
40
|
+
pathParam(identifier: string): string;
|
|
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
|
+
|
|
50
|
+
/** Opening line of a handler, including its middleware and the handler function's parameters. */
|
|
51
|
+
routeOpen(routerName: string, method: string, path: string, middlewares: readonly string[]): string;
|
|
52
|
+
|
|
53
|
+
/** Lines that close a handler opened by {@link routeOpen}. */
|
|
54
|
+
routeClose(): string[];
|
|
55
|
+
|
|
56
|
+
/** Route middleware factory calls, rendered as expressions for {@link routeOpen}. */
|
|
57
|
+
readonly middleware: {
|
|
58
|
+
policy(args: string): string;
|
|
59
|
+
bodyParser(tokensExpr: string): string;
|
|
60
|
+
signature(args: string): string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Expressions a handler reads the request through. */
|
|
64
|
+
readonly request: {
|
|
65
|
+
params: string;
|
|
66
|
+
query: string;
|
|
67
|
+
headers: string;
|
|
68
|
+
/** The body already parsed by the body-parser middleware. */
|
|
69
|
+
parsedBody: string;
|
|
70
|
+
/**
|
|
71
|
+
* The request's media type with any parameters stripped. It is matched against declared MIME
|
|
72
|
+
* literals, so an adapter whose framework exposes only the raw header must normalise it here
|
|
73
|
+
* — a `; charset=utf-8` left on the end matches nothing.
|
|
74
|
+
*/
|
|
75
|
+
contentType: string;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Expression resolving a service class out of the request-scoped DI container. */
|
|
79
|
+
resolveService(className: string): string;
|
|
80
|
+
|
|
81
|
+
/** Statements a handler writes the response with. */
|
|
82
|
+
readonly response: {
|
|
83
|
+
status(expr: string): string;
|
|
84
|
+
/**
|
|
85
|
+
* One statement setting a response header. It is emitted bare or behind an `if` guard for an
|
|
86
|
+
* optional header, so it must stay a single statement.
|
|
87
|
+
*/
|
|
88
|
+
header(name: string, valueExpr: string): string;
|
|
89
|
+
type(expr: string): string;
|
|
90
|
+
/**
|
|
91
|
+
* The terminal write for a response, or for one without a body when `bodyExpr` is undefined.
|
|
92
|
+
* A framework that ends a response by returning needs a statement in both cases; Koa, which
|
|
93
|
+
* ends it by assignment, emits nothing for a bodyless one.
|
|
94
|
+
*/
|
|
95
|
+
send(bodyExpr: string | undefined): string[];
|
|
96
|
+
/** What closes one `case` of the multi-status switch, after that status has been written. */
|
|
97
|
+
caseEnd(): string[];
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** The whole `mcp.router.ts` file, which is boilerplate rather than a per-operation render. */
|
|
101
|
+
mcpRouter(options: { path: string }): string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Every supported framework, keyed by name. The annotation is what ties this to
|
|
106
|
+
* {@link SERVER_FRAMEWORK_NAMES}: adding a name without an adapter is a compile error.
|
|
107
|
+
*/
|
|
108
|
+
export const SERVER_FRAMEWORKS: Readonly<Record<ServerFrameworkName, ServerFramework>> = {
|
|
109
|
+
koa: KOA_SERVER_FRAMEWORK,
|
|
110
|
+
fastify: FASTIFY_SERVER_FRAMEWORK,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Resolve a configured framework name to its adapter.
|
|
115
|
+
*
|
|
116
|
+
* @param name The `server.framework` value, or undefined for {@link DEFAULT_SERVER_FRAMEWORK_NAME}.
|
|
117
|
+
* @throws When `name` is not a supported framework. Config arrives as JSON, so this is a runtime
|
|
118
|
+
* check and not something the `ServerFrameworkName` type can enforce on its own.
|
|
119
|
+
*/
|
|
120
|
+
export function resolveServerFramework(name: string | undefined): ServerFramework {
|
|
121
|
+
const resolved = name ?? DEFAULT_SERVER_FRAMEWORK_NAME;
|
|
122
|
+
const framework = (SERVER_FRAMEWORKS as Record<string, ServerFramework | undefined>)[resolved];
|
|
123
|
+
if (!framework) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`plugin-typescript: server.framework '${resolved}' is not supported — expected one of: ${SERVER_FRAMEWORK_NAMES.join(', ')}.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return framework;
|
|
129
|
+
}
|
|
@@ -286,6 +286,18 @@ describe('generateMcpRouter', () => {
|
|
|
286
286
|
expect(out).not.toContain('ctx.request.body');
|
|
287
287
|
});
|
|
288
288
|
|
|
289
|
+
it('delegates the whole file to the framework adapter', () => {
|
|
290
|
+
const stub = { mcpRouter: ({ path }: { path: string }) => `// stub mount at ${path}` };
|
|
291
|
+
// Only `mcpRouter` is reachable from here, so the rest of the adapter is left off the stub.
|
|
292
|
+
const out = generateMcpRouter({ framework: stub as never });
|
|
293
|
+
expect(out).toBe('// stub mount at /mcp');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('passes a configured path through to the adapter', () => {
|
|
297
|
+
const stub = { mcpRouter: ({ path }: { path: string }) => `// stub mount at ${path}` };
|
|
298
|
+
expect(generateMcpRouter({ path: '/tools', framework: stub as never })).toBe('// stub mount at /tools');
|
|
299
|
+
});
|
|
300
|
+
|
|
289
301
|
it('defaults the mount path to /mcp', () => {
|
|
290
302
|
expect(generateMcpRouter()).toContain("router.post('/mcp'");
|
|
291
303
|
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generateOp } from '../src/codegen-operation.js';
|
|
3
|
+
import type { ServerFramework } from '../src/server-framework.js';
|
|
4
|
+
import { FASTIFY_SERVER_FRAMEWORK } from '../src/server-framework-fastify.js';
|
|
5
|
+
import { scalarType, refType, opParam, opRequest, opMultiRequest, opResponse, opResponseMulti, opOperation, opRoute, opRoot } from './helpers.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A framework whose every string is unmistakable. Rendering a router through it and finding no Koa
|
|
9
|
+
* left in the output is what proves the seam is complete: a `ctx.` the generator still emits inline
|
|
10
|
+
* would survive this substitution, and a `toContain` test against the Koa output never notices,
|
|
11
|
+
* because the default adapter puts the very same string there.
|
|
12
|
+
*/
|
|
13
|
+
const STUB: ServerFramework = {
|
|
14
|
+
// The registry's key type admits only shipped frameworks; the adapter under test is a fake.
|
|
15
|
+
name: 'koa',
|
|
16
|
+
imports: uses => (uses('StubRouter') ? ["import { StubRouter } from '@stub/http';"] : []),
|
|
17
|
+
routerDeclaration: routerName => `export const ${routerName} = StubRouter();`,
|
|
18
|
+
pathParam: identifier => `<${identifier}>`,
|
|
19
|
+
handlerLocals: ['rq', 'rs'],
|
|
20
|
+
routeOpen: (routerName, method, path, middlewares) => `${routerName}.route('${method}', '${path}', [${middlewares.join(', ')}], async (rq, rs) => {`,
|
|
21
|
+
routeClose: () => ['}, END);'],
|
|
22
|
+
middleware: {
|
|
23
|
+
policy: args => `stubPolicy(${args})`,
|
|
24
|
+
bodyParser: tokens => `stubBody(${tokens})`,
|
|
25
|
+
signature: args => `stubSignature(${args})`,
|
|
26
|
+
},
|
|
27
|
+
request: {
|
|
28
|
+
params: 'rq.pathParams',
|
|
29
|
+
query: 'rq.searchParams',
|
|
30
|
+
headers: 'rq.headerBag',
|
|
31
|
+
parsedBody: 'rq.payload',
|
|
32
|
+
contentType: 'rq.mediaType',
|
|
33
|
+
},
|
|
34
|
+
resolveService: className => `rq.services.resolve(${className})`,
|
|
35
|
+
response: {
|
|
36
|
+
status: expr => `rs.setStatus(${expr});`,
|
|
37
|
+
header: (name, valueExpr) => `rs.putHeader('${name}', ${valueExpr});`,
|
|
38
|
+
type: expr => `rs.setMedia(${expr});`,
|
|
39
|
+
send: bodyExpr => (bodyExpr === undefined ? ['return rs.finish();'] : [`return rs.deliver(${bodyExpr});`]),
|
|
40
|
+
caseEnd: () => [],
|
|
41
|
+
},
|
|
42
|
+
mcpRouter: ({ path }) => `// stub mcp at ${path}\n`,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** One op root touching every branch of the generator that can emit a framework string. */
|
|
46
|
+
function everyBranchRoot() {
|
|
47
|
+
return opRoot([
|
|
48
|
+
opRoute(
|
|
49
|
+
'/payments/{paymentId}',
|
|
50
|
+
[
|
|
51
|
+
// Path params + query + headers + a single JSON body, with policy and signature middleware.
|
|
52
|
+
opOperation('post', {
|
|
53
|
+
request: opRequest('Payment'),
|
|
54
|
+
query: [opParam('limit', scalarType('int'))],
|
|
55
|
+
headers: [opParam('x-tenant', scalarType('string'))],
|
|
56
|
+
signature: 'stripe',
|
|
57
|
+
responses: [opResponse(201, 'Payment')],
|
|
58
|
+
}),
|
|
59
|
+
// Several request MIMEs with different shapes — the content-type switch.
|
|
60
|
+
opOperation('put', {
|
|
61
|
+
request: opMultiRequest([
|
|
62
|
+
['application/json', 'Payment'],
|
|
63
|
+
['multipart/form-data', 'Receipt'],
|
|
64
|
+
]),
|
|
65
|
+
responses: [opResponse(200, 'Payment')],
|
|
66
|
+
}),
|
|
67
|
+
// No emitted body at all — the bodyless 204 path.
|
|
68
|
+
opOperation('delete', { responses: [] }),
|
|
69
|
+
// Several emitted statuses, one carrying response headers — the status switch.
|
|
70
|
+
opOperation('patch', {
|
|
71
|
+
responses: [
|
|
72
|
+
opResponseMulti(200, [{ contentType: 'application/json', bodyType: 'Payment' }], {
|
|
73
|
+
headers: [{ name: 'etag', optional: false, type: scalarType('string') }],
|
|
74
|
+
}),
|
|
75
|
+
opResponseMulti(202, [{ contentType: 'application/json', bodyType: refType('Payment') }], {
|
|
76
|
+
headers: [{ name: 'retry-after', optional: true, type: scalarType('string') }],
|
|
77
|
+
}),
|
|
78
|
+
],
|
|
79
|
+
}),
|
|
80
|
+
],
|
|
81
|
+
[opParam('paymentId', scalarType('uuid'))],
|
|
82
|
+
),
|
|
83
|
+
]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe('generateOp — framework seam', () => {
|
|
87
|
+
const output = generateOp(everyBranchRoot(), { framework: STUB });
|
|
88
|
+
|
|
89
|
+
it('leaves no Koa string anywhere in the output', () => {
|
|
90
|
+
expect(output).not.toMatch(/\bctx\b/);
|
|
91
|
+
expect(output).not.toContain('ServerKitRouter');
|
|
92
|
+
expect(output).not.toContain('@maroonedsoftware/koa');
|
|
93
|
+
expect(output).not.toContain('requirePolicy');
|
|
94
|
+
expect(output).not.toContain('bodyParserMiddleware');
|
|
95
|
+
expect(output).not.toContain('requireSignature');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('renders the router shell through the adapter', () => {
|
|
99
|
+
expect(output).toContain('export const UsersRouter = StubRouter();');
|
|
100
|
+
expect(output).toContain("import { StubRouter } from '@stub/http';");
|
|
101
|
+
expect(output).toContain('}, END);');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('renders the route line, its path params and its middleware through the adapter', () => {
|
|
105
|
+
expect(output).toContain("UsersRouter.route('post', '/payments/<paymentId>', [stubPolicy(), stubBody('json'), stubSignature('stripe')], async (rq, rs) => {");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('reads params, query, headers and the body through the adapter', () => {
|
|
109
|
+
expect(output).toContain('rq.pathParams');
|
|
110
|
+
expect(output).toContain('rq.searchParams');
|
|
111
|
+
expect(output).toContain('rq.headerBag');
|
|
112
|
+
expect(output).toContain('rq.payload');
|
|
113
|
+
expect(output).toContain('switch (rq.mediaType) {');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('resolves services through the adapter', () => {
|
|
117
|
+
expect(output).toContain('rq.services.resolve(UsersService)');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('writes status, headers, content type and body through the adapter', () => {
|
|
121
|
+
expect(output).toContain('rs.setStatus(201);');
|
|
122
|
+
expect(output).toContain("rs.setMedia('application/json');");
|
|
123
|
+
expect(output).toContain('return rs.deliver(result);');
|
|
124
|
+
expect(output).toContain('rs.putHeader(\'etag\', String(result.headers["etag"]));');
|
|
125
|
+
expect(output).toContain('if (result.headers["retryAfter"] !== undefined) rs.putHeader(\'retry-after\', String(result.headers["retryAfter"]));');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('gives a bodyless response the adapter\'s terminal statement', () => {
|
|
129
|
+
// Koa needs none, so the generator only emits one because the adapter asked for it.
|
|
130
|
+
expect(output).toContain('rs.setStatus(204);');
|
|
131
|
+
expect(output).toContain('return rs.finish();');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('omits the status-case terminator when the adapter has none', () => {
|
|
135
|
+
expect(output).toContain('rs.setStatus(result.status);');
|
|
136
|
+
// Scoped to the status switch: the multi-MIME request dispatch is the generator's own control
|
|
137
|
+
// flow and keeps its `break;` whatever the framework is.
|
|
138
|
+
const statusSwitch = output.slice(output.indexOf('switch (result.status) {'));
|
|
139
|
+
expect(statusSwitch).toContain('case 202:');
|
|
140
|
+
expect(statusSwitch).not.toContain('break;');
|
|
141
|
+
});
|
|
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([
|
|
@@ -567,6 +612,35 @@ describe('generateOperation', () => {
|
|
|
567
612
|
expect(output).toContain('z.object({');
|
|
568
613
|
});
|
|
569
614
|
|
|
615
|
+
it('renders an inline array header through renderInputType, without the query coercion', () => {
|
|
616
|
+
// The comma-splitting preprocess belongs to query strings only. Headers and params share
|
|
617
|
+
// the same inline-param renderer, so the block has to be told which one it is rendering.
|
|
618
|
+
const root = opRoot([
|
|
619
|
+
opRoute('/users', [
|
|
620
|
+
opOperation('get', {
|
|
621
|
+
headers: [opParam('x-tags', arrayType(scalarType('string')))],
|
|
622
|
+
}),
|
|
623
|
+
]),
|
|
624
|
+
]);
|
|
625
|
+
const output = generateOp(root);
|
|
626
|
+
expect(output).toContain('ctx.headers');
|
|
627
|
+
expect(output).not.toContain('z.preprocess');
|
|
628
|
+
expect(output).not.toContain("v.split(',')");
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('declares headers as a whole object rather than destructuring it', () => {
|
|
632
|
+
const root = opRoot([
|
|
633
|
+
opRoute('/users', [
|
|
634
|
+
opOperation('get', {
|
|
635
|
+
headers: [opParam('authorization', scalarType('string'))],
|
|
636
|
+
}),
|
|
637
|
+
]),
|
|
638
|
+
]);
|
|
639
|
+
const output = generateOp(root);
|
|
640
|
+
expect(output).toContain('const headers = await parseAndValidate(');
|
|
641
|
+
expect(output).not.toContain('const { authorization }');
|
|
642
|
+
});
|
|
643
|
+
|
|
570
644
|
it('generates parseAndValidate import when operation has headers', () => {
|
|
571
645
|
const root = opRoot([
|
|
572
646
|
opRoute('/users', [
|