@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.
@@ -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', [