@contractkit/plugin-typescript 0.34.1 → 0.35.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 +23 -18
- package/CHANGELOG.md +22 -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 +12 -4
- package/dist/codegen-operation.d.ts.map +1 -1
- package/dist/index.d.ts +11 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +215 -88
- package/dist/index.js.map +1 -1
- 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 +94 -0
- package/dist/server-framework.d.ts.map +1 -0
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/src/codegen-mcp.ts +11 -25
- package/src/codegen-operation.ts +99 -74
- package/src/index.ts +36 -5
- package/src/server-framework-koa.ts +112 -0
- package/src/server-framework.ts +119 -0
- package/tests/codegen-mcp.test.ts +12 -0
- package/tests/codegen-operation-framework.test.ts +140 -0
- package/tests/codegen-operation.test.ts +29 -0
- package/tests/codegen-server.test.ts +56 -0
- package/tests/server-framework-koa.test.ts +115 -0
- package/tests/server-framework.test.ts +24 -0
package/src/index.ts
CHANGED
|
@@ -45,6 +45,16 @@ import {
|
|
|
45
45
|
} from './codegen-sdk.js';
|
|
46
46
|
import { generatePlainTypes } from './codegen-plain-types.js';
|
|
47
47
|
import { DEFAULT_REVIVABLE_SCALARS } from './codegen-revive.js';
|
|
48
|
+
import { resolveServerFramework, SERVER_FRAMEWORK_NAMES, type ServerFrameworkName } from './server-framework.js';
|
|
49
|
+
export {
|
|
50
|
+
SERVER_FRAMEWORK_NAMES,
|
|
51
|
+
DEFAULT_SERVER_FRAMEWORK_NAME,
|
|
52
|
+
resolveServerFramework,
|
|
53
|
+
SERVER_FRAMEWORKS,
|
|
54
|
+
type ServerFramework,
|
|
55
|
+
type ServerFrameworkName,
|
|
56
|
+
} from './server-framework.js';
|
|
57
|
+
export { KOA_SERVER_FRAMEWORK } from './server-framework-koa.js';
|
|
48
58
|
|
|
49
59
|
/** Taint set for the SDK's bigint response reviver. */
|
|
50
60
|
const BIGINT_SCALARS: ReadonlySet<ScalarTypeNode['name']> = new Set(['bigint']);
|
|
@@ -65,14 +75,19 @@ import {
|
|
|
65
75
|
|
|
66
76
|
// ─── Sub-config interfaces ─────────────────────────────────────────────────
|
|
67
77
|
|
|
68
|
-
/**
|
|
78
|
+
/** Server output: routers, and the type or Zod schema files they import. */
|
|
69
79
|
export interface ServerConfig {
|
|
70
80
|
/** Directory (relative to rootDir) where server files are written. Default: rootDir. */
|
|
71
81
|
baseDir?: string;
|
|
82
|
+
/**
|
|
83
|
+
* HTTP framework the generated routers target. Also selects the flavour of the optional
|
|
84
|
+
* `mcp.router.ts` the `mcp` sub-config emits. Supported: `'koa'`. Default `'koa'`.
|
|
85
|
+
*/
|
|
86
|
+
framework?: ServerFrameworkName;
|
|
72
87
|
/** When true, `output.types` emits Zod schema files (via `generateContract`). When false/omitted, emits plain TypeScript. */
|
|
73
88
|
zod?: boolean;
|
|
74
89
|
output?: {
|
|
75
|
-
/** Path template for
|
|
90
|
+
/** Path template for router files. Supports {filename}, {dir}, {area}. */
|
|
76
91
|
routes?: string;
|
|
77
92
|
/** Path template for type/schema files. Supports {filename}, {dir}, {area}. */
|
|
78
93
|
types?: string;
|
|
@@ -151,7 +166,7 @@ export interface McpConfig {
|
|
|
151
166
|
*/
|
|
152
167
|
types?: string;
|
|
153
168
|
};
|
|
154
|
-
/** Emit the `mcp.router.ts` route boilerplate. Default true. */
|
|
169
|
+
/** Emit the `mcp.router.ts` route boilerplate. Its framework follows `server.framework`. Default true. */
|
|
155
170
|
emitRouter?: boolean;
|
|
156
171
|
/** Mount path used in the emitted router. Default `/mcp`. */
|
|
157
172
|
path?: string;
|
|
@@ -207,6 +222,14 @@ export function createTypescriptPlugin(config: TypescriptPluginConfig, rootDir:
|
|
|
207
222
|
|
|
208
223
|
/** Reject config combinations that would generate code that cannot compile or cannot run. */
|
|
209
224
|
function assertValidConfig(config: TypescriptPluginConfig): void {
|
|
225
|
+
// Runtime check, not just a type: config arrives as JSON, so `ServerFrameworkName` constrains
|
|
226
|
+
// programmatic callers only. Checked before the rest so a typo'd framework is the error reported.
|
|
227
|
+
const framework = config.server?.framework;
|
|
228
|
+
if (framework !== undefined && !(SERVER_FRAMEWORK_NAMES as readonly string[]).includes(framework)) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`plugin-typescript: server.framework '${String(framework)}' is not supported — expected one of: ${SERVER_FRAMEWORK_NAMES.join(', ')}.`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
210
233
|
if (config.server?.validateResponses && !config.server.zod) {
|
|
211
234
|
throw new Error(
|
|
212
235
|
'plugin-typescript: server.validateResponses requires server.zod: true — without it output.types emits plain TypeScript interfaces, which are types with no runtime schema value for the router to validate against.',
|
|
@@ -367,6 +390,7 @@ function collectServerOutput(
|
|
|
367
390
|
units: IncrementalUnit[],
|
|
368
391
|
): void {
|
|
369
392
|
const serverBase = resolve(rootDir, config.baseDir ?? '.');
|
|
393
|
+
const framework = resolveServerFramework(config.framework);
|
|
370
394
|
const modelsWithInput = inputs.modelsWithInput as Set<string>;
|
|
371
395
|
const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
|
|
372
396
|
// Not `modelsWithOutput`: that set seeds only from `format(output=...)`, because only that case
|
|
@@ -418,7 +442,7 @@ function collectServerOutput(
|
|
|
418
442
|
currentOutPath: typeOutPath,
|
|
419
443
|
modelsWithInput,
|
|
420
444
|
modelsWithOutput,
|
|
421
|
-
// These types are consumed by
|
|
445
|
+
// These types are consumed by server handlers, so `binary` is a Buffer, not a Blob.
|
|
422
446
|
target: 'server' as const,
|
|
423
447
|
};
|
|
424
448
|
const content = config.zod ? generateContract(ast, renderCtx) : generatePlainTypes(ast, renderCtx);
|
|
@@ -446,6 +470,9 @@ function collectServerOutput(
|
|
|
446
470
|
// this router's output with no change to `root` or the config.
|
|
447
471
|
modelsWithTransform: sliceModelSet(refs, new Set(), modelsWithTransform),
|
|
448
472
|
validateResponses: config.validateResponses ?? false,
|
|
473
|
+
// Covered by `sub` already, which is the whole sub-config; explicit for the same reason
|
|
474
|
+
// `validateResponses` is — the inputs that change a router's text read at a glance.
|
|
475
|
+
framework: framework.name,
|
|
449
476
|
sub: subConfigKey,
|
|
450
477
|
});
|
|
451
478
|
units.push({
|
|
@@ -463,6 +490,7 @@ function collectServerOutput(
|
|
|
463
490
|
modelsWithTransform,
|
|
464
491
|
includeInternal: config.includeInternal,
|
|
465
492
|
validateResponses: config.validateResponses,
|
|
493
|
+
framework,
|
|
466
494
|
}),
|
|
467
495
|
},
|
|
468
496
|
],
|
|
@@ -1107,7 +1135,10 @@ function collectMcpOutput(
|
|
|
1107
1135
|
// ── Router (global, optional) ──
|
|
1108
1136
|
if (config.emitRouter !== false) {
|
|
1109
1137
|
const routerPath = join(mcpBase, config.output?.router ?? 'mcp.router.ts');
|
|
1110
|
-
|
|
1138
|
+
// The mount is server-side wiring, so it follows the server sub-config's framework. Koa when
|
|
1139
|
+
// there is no `server` sub-config at all, which is the same default the router generator has.
|
|
1140
|
+
const framework = resolveServerFramework(fullConfig.server?.framework);
|
|
1141
|
+
globalFiles.push({ relativePath: routerPath, content: generateMcpRouter({ path: config.path, framework }) });
|
|
1111
1142
|
}
|
|
1112
1143
|
}
|
|
1113
1144
|
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
routeOpen(routerName, method, path, middlewares) {
|
|
33
|
+
const middlewareStr = middlewares.length > 0 ? `, ${middlewares.join(', ')},` : ',';
|
|
34
|
+
return `${routerName}.${method}('${path}'${middlewareStr} async ctx => {`;
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
routeClose() {
|
|
38
|
+
return ['});'];
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
middleware: {
|
|
42
|
+
policy(args) {
|
|
43
|
+
return `requirePolicy(${args})`;
|
|
44
|
+
},
|
|
45
|
+
bodyParser(tokensExpr) {
|
|
46
|
+
return `bodyParserMiddleware([${tokensExpr}])`;
|
|
47
|
+
},
|
|
48
|
+
signature(args) {
|
|
49
|
+
return `requireSignature(${args})`;
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
request: {
|
|
54
|
+
params: 'ctx.params',
|
|
55
|
+
query: 'ctx.query',
|
|
56
|
+
headers: 'ctx.headers',
|
|
57
|
+
// Not `ctx.request.body`: the ServerKit body parser drains the stream and writes its result
|
|
58
|
+
// here, and in Koa `ctx.body` is the *response* body.
|
|
59
|
+
parsedBody: 'ctx.parsedBody',
|
|
60
|
+
// Koa strips the parameters off `Content-Type` for this accessor already.
|
|
61
|
+
contentType: 'ctx.request.type',
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
resolveService(className) {
|
|
65
|
+
return `ctx.container.get(${className})`;
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
response: {
|
|
69
|
+
status(expr) {
|
|
70
|
+
return `ctx.status = ${expr};`;
|
|
71
|
+
},
|
|
72
|
+
header(name, valueExpr) {
|
|
73
|
+
return `ctx.set('${name}', ${valueExpr});`;
|
|
74
|
+
},
|
|
75
|
+
type(expr) {
|
|
76
|
+
return `ctx.type = ${expr};`;
|
|
77
|
+
},
|
|
78
|
+
send(bodyExpr) {
|
|
79
|
+
// A bodyless response needs no statement at all: Koa sends whatever `ctx.status` and the
|
|
80
|
+
// headers say once the handler resolves.
|
|
81
|
+
return bodyExpr === undefined ? [] : [`ctx.body = ${bodyExpr};`];
|
|
82
|
+
},
|
|
83
|
+
caseEnd() {
|
|
84
|
+
return ['break;'];
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
mcpRouter({ path }) {
|
|
89
|
+
return `import { ServerKitRouter, bodyParserMiddleware, requireSignature } from '${KOA_RUNTIME_MODULE}';
|
|
90
|
+
import { McpDispatcher, createMcpRequestContext, MCP_AUTH_POLICY } from '@maroonedsoftware/mcp';
|
|
91
|
+
|
|
92
|
+
/** Mount the MCP endpoint onto a ServerKit router. Bind \`registerMcpTools\` to the \`McpToolHandlerMap\` token. */
|
|
93
|
+
export function mountMcp(router: ReturnType<typeof ServerKitRouter>): void {
|
|
94
|
+
router.post('${path}', bodyParserMiddleware(['json']), requireSignature('mcp', { policy: MCP_AUTH_POLICY }), async (ctx) => {
|
|
95
|
+
const dispatcher = ctx.container.get(McpDispatcher);
|
|
96
|
+
const context = createMcpRequestContext({ requestId: ctx.requestId, logger: ctx.logger });
|
|
97
|
+
if (dispatcher.sessionMode === 'stateful') {
|
|
98
|
+
ctx.respond = false;
|
|
99
|
+
await dispatcher.dispatchStateful(
|
|
100
|
+
{ req: ctx.req, res: ctx.res, body: ctx.parsedBody, sessionId: ctx.get('mcp-session-id') },
|
|
101
|
+
context,
|
|
102
|
+
);
|
|
103
|
+
} else {
|
|
104
|
+
const response = await dispatcher.dispatch(JSON.parse(String(ctx.rawBody)), context);
|
|
105
|
+
if (response) ctx.body = response;
|
|
106
|
+
else ctx.status = 202; // a notification — nothing to return
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
`;
|
|
111
|
+
},
|
|
112
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { KOA_SERVER_FRAMEWORK } from './server-framework-koa.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HTTP frameworks the server sub-generator can target. Adding a name here without adding an adapter
|
|
5
|
+
* to {@link SERVER_FRAMEWORKS} fails to compile, which is the point of keeping the two in step.
|
|
6
|
+
*/
|
|
7
|
+
export const SERVER_FRAMEWORK_NAMES = ['koa'] as const;
|
|
8
|
+
|
|
9
|
+
/** One of {@link SERVER_FRAMEWORK_NAMES}. */
|
|
10
|
+
export type ServerFrameworkName = (typeof SERVER_FRAMEWORK_NAMES)[number];
|
|
11
|
+
|
|
12
|
+
/** The framework assumed when a config names none. */
|
|
13
|
+
export const DEFAULT_SERVER_FRAMEWORK_NAME: ServerFrameworkName = 'koa';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Every framework-specific string the router and MCP router generators emit.
|
|
17
|
+
*
|
|
18
|
+
* Granularity is one statement (or one fragment) per method, so the shared codegen keeps ownership
|
|
19
|
+
* of control flow — which branches exist, what order they run in, and which values reach them — and
|
|
20
|
+
* an adapter only decides how a given step is spelled. Anything an adapter cannot express as a
|
|
21
|
+
* statement, such as ending a response, is returned as a list of lines so it can also be empty.
|
|
22
|
+
*/
|
|
23
|
+
export interface ServerFramework {
|
|
24
|
+
readonly name: ServerFrameworkName;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Import lines for the framework runtime, already filtered down to what the generated body uses.
|
|
28
|
+
*
|
|
29
|
+
* The adapter applies `uses` itself rather than declaring a symbol list, because a framework may
|
|
30
|
+
* need more than one import line, and because only names the adapter chooses ever go through the
|
|
31
|
+
* word-boundary probe — a handler-local identifier can never be mistaken for an import.
|
|
32
|
+
*/
|
|
33
|
+
imports(uses: (symbol: string) => boolean): string[];
|
|
34
|
+
|
|
35
|
+
/** The module-level router value every handler attaches to. */
|
|
36
|
+
routerDeclaration(routerName: string): string;
|
|
37
|
+
|
|
38
|
+
/** Placeholder syntax for one path parameter, given a name already mapped to a valid identifier. */
|
|
39
|
+
pathParam(identifier: string): string;
|
|
40
|
+
|
|
41
|
+
/** Opening line of a handler, including its middleware and the handler function's parameters. */
|
|
42
|
+
routeOpen(routerName: string, method: string, path: string, middlewares: readonly string[]): string;
|
|
43
|
+
|
|
44
|
+
/** Lines that close a handler opened by {@link routeOpen}. */
|
|
45
|
+
routeClose(): string[];
|
|
46
|
+
|
|
47
|
+
/** Route middleware factory calls, rendered as expressions for {@link routeOpen}. */
|
|
48
|
+
readonly middleware: {
|
|
49
|
+
policy(args: string): string;
|
|
50
|
+
bodyParser(tokensExpr: string): string;
|
|
51
|
+
signature(args: string): string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Expressions a handler reads the request through. */
|
|
55
|
+
readonly request: {
|
|
56
|
+
params: string;
|
|
57
|
+
query: string;
|
|
58
|
+
headers: string;
|
|
59
|
+
/** The body already parsed by the body-parser middleware. */
|
|
60
|
+
parsedBody: string;
|
|
61
|
+
/**
|
|
62
|
+
* The request's media type with any parameters stripped. It is matched against declared MIME
|
|
63
|
+
* literals, so an adapter whose framework exposes only the raw header must normalise it here
|
|
64
|
+
* — a `; charset=utf-8` left on the end matches nothing.
|
|
65
|
+
*/
|
|
66
|
+
contentType: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Expression resolving a service class out of the request-scoped DI container. */
|
|
70
|
+
resolveService(className: string): string;
|
|
71
|
+
|
|
72
|
+
/** Statements a handler writes the response with. */
|
|
73
|
+
readonly response: {
|
|
74
|
+
status(expr: string): string;
|
|
75
|
+
/**
|
|
76
|
+
* One statement setting a response header. It is emitted bare or behind an `if` guard for an
|
|
77
|
+
* optional header, so it must stay a single statement.
|
|
78
|
+
*/
|
|
79
|
+
header(name: string, valueExpr: string): string;
|
|
80
|
+
type(expr: string): string;
|
|
81
|
+
/**
|
|
82
|
+
* The terminal write for a response, or for one without a body when `bodyExpr` is undefined.
|
|
83
|
+
* A framework that ends a response by returning needs a statement in both cases; Koa, which
|
|
84
|
+
* ends it by assignment, emits nothing for a bodyless one.
|
|
85
|
+
*/
|
|
86
|
+
send(bodyExpr: string | undefined): string[];
|
|
87
|
+
/** What closes one `case` of the multi-status switch, after that status has been written. */
|
|
88
|
+
caseEnd(): string[];
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** The whole `mcp.router.ts` file, which is boilerplate rather than a per-operation render. */
|
|
92
|
+
mcpRouter(options: { path: string }): string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Every supported framework, keyed by name. The annotation is what ties this to
|
|
97
|
+
* {@link SERVER_FRAMEWORK_NAMES}: adding a name without an adapter is a compile error.
|
|
98
|
+
*/
|
|
99
|
+
export const SERVER_FRAMEWORKS: Readonly<Record<ServerFrameworkName, ServerFramework>> = {
|
|
100
|
+
koa: KOA_SERVER_FRAMEWORK,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolve a configured framework name to its adapter.
|
|
105
|
+
*
|
|
106
|
+
* @param name The `server.framework` value, or undefined for {@link DEFAULT_SERVER_FRAMEWORK_NAME}.
|
|
107
|
+
* @throws When `name` is not a supported framework. Config arrives as JSON, so this is a runtime
|
|
108
|
+
* check and not something the `ServerFrameworkName` type can enforce on its own.
|
|
109
|
+
*/
|
|
110
|
+
export function resolveServerFramework(name: string | undefined): ServerFramework {
|
|
111
|
+
const resolved = name ?? DEFAULT_SERVER_FRAMEWORK_NAME;
|
|
112
|
+
const framework = (SERVER_FRAMEWORKS as Record<string, ServerFramework | undefined>)[resolved];
|
|
113
|
+
if (!framework) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`plugin-typescript: server.framework '${resolved}' is not supported — expected one of: ${SERVER_FRAMEWORK_NAMES.join(', ')}.`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return framework;
|
|
119
|
+
}
|
|
@@ -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,140 @@
|
|
|
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 { scalarType, refType, opParam, opRequest, opMultiRequest, opResponse, opResponseMulti, opOperation, opRoute, opRoot } from './helpers.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A framework whose every string is unmistakable. Rendering a router through it and finding no Koa
|
|
8
|
+
* left in the output is what proves the seam is complete: a `ctx.` the generator still emits inline
|
|
9
|
+
* would survive this substitution, and a `toContain` test against the Koa output never notices,
|
|
10
|
+
* because the default adapter puts the very same string there.
|
|
11
|
+
*/
|
|
12
|
+
const STUB: ServerFramework = {
|
|
13
|
+
// The registry's key type admits only shipped frameworks; the adapter under test is a fake.
|
|
14
|
+
name: 'koa',
|
|
15
|
+
imports: uses => (uses('StubRouter') ? ["import { StubRouter } from '@stub/http';"] : []),
|
|
16
|
+
routerDeclaration: routerName => `export const ${routerName} = StubRouter();`,
|
|
17
|
+
pathParam: identifier => `<${identifier}>`,
|
|
18
|
+
routeOpen: (routerName, method, path, middlewares) => `${routerName}.route('${method}', '${path}', [${middlewares.join(', ')}], async (rq, rs) => {`,
|
|
19
|
+
routeClose: () => ['}, END);'],
|
|
20
|
+
middleware: {
|
|
21
|
+
policy: args => `stubPolicy(${args})`,
|
|
22
|
+
bodyParser: tokens => `stubBody(${tokens})`,
|
|
23
|
+
signature: args => `stubSignature(${args})`,
|
|
24
|
+
},
|
|
25
|
+
request: {
|
|
26
|
+
params: 'rq.pathParams',
|
|
27
|
+
query: 'rq.searchParams',
|
|
28
|
+
headers: 'rq.headerBag',
|
|
29
|
+
parsedBody: 'rq.payload',
|
|
30
|
+
contentType: 'rq.mediaType',
|
|
31
|
+
},
|
|
32
|
+
resolveService: className => `rq.services.resolve(${className})`,
|
|
33
|
+
response: {
|
|
34
|
+
status: expr => `rs.setStatus(${expr});`,
|
|
35
|
+
header: (name, valueExpr) => `rs.putHeader('${name}', ${valueExpr});`,
|
|
36
|
+
type: expr => `rs.setMedia(${expr});`,
|
|
37
|
+
send: bodyExpr => (bodyExpr === undefined ? ['return rs.finish();'] : [`return rs.deliver(${bodyExpr});`]),
|
|
38
|
+
caseEnd: () => [],
|
|
39
|
+
},
|
|
40
|
+
mcpRouter: ({ path }) => `// stub mcp at ${path}\n`,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** One op root touching every branch of the generator that can emit a framework string. */
|
|
44
|
+
function everyBranchRoot() {
|
|
45
|
+
return opRoot([
|
|
46
|
+
opRoute(
|
|
47
|
+
'/payments/{paymentId}',
|
|
48
|
+
[
|
|
49
|
+
// Path params + query + headers + a single JSON body, with policy and signature middleware.
|
|
50
|
+
opOperation('post', {
|
|
51
|
+
request: opRequest('Payment'),
|
|
52
|
+
query: [opParam('limit', scalarType('int'))],
|
|
53
|
+
headers: [opParam('x-tenant', scalarType('string'))],
|
|
54
|
+
signature: 'stripe',
|
|
55
|
+
responses: [opResponse(201, 'Payment')],
|
|
56
|
+
}),
|
|
57
|
+
// Several request MIMEs with different shapes — the content-type switch.
|
|
58
|
+
opOperation('put', {
|
|
59
|
+
request: opMultiRequest([
|
|
60
|
+
['application/json', 'Payment'],
|
|
61
|
+
['multipart/form-data', 'Receipt'],
|
|
62
|
+
]),
|
|
63
|
+
responses: [opResponse(200, 'Payment')],
|
|
64
|
+
}),
|
|
65
|
+
// No emitted body at all — the bodyless 204 path.
|
|
66
|
+
opOperation('delete', { responses: [] }),
|
|
67
|
+
// Several emitted statuses, one carrying response headers — the status switch.
|
|
68
|
+
opOperation('patch', {
|
|
69
|
+
responses: [
|
|
70
|
+
opResponseMulti(200, [{ contentType: 'application/json', bodyType: 'Payment' }], {
|
|
71
|
+
headers: [{ name: 'etag', optional: false, type: scalarType('string') }],
|
|
72
|
+
}),
|
|
73
|
+
opResponseMulti(202, [{ contentType: 'application/json', bodyType: refType('Payment') }], {
|
|
74
|
+
headers: [{ name: 'retry-after', optional: true, type: scalarType('string') }],
|
|
75
|
+
}),
|
|
76
|
+
],
|
|
77
|
+
}),
|
|
78
|
+
],
|
|
79
|
+
[opParam('paymentId', scalarType('uuid'))],
|
|
80
|
+
),
|
|
81
|
+
]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe('generateOp — framework seam', () => {
|
|
85
|
+
const output = generateOp(everyBranchRoot(), { framework: STUB });
|
|
86
|
+
|
|
87
|
+
it('leaves no Koa string anywhere in the output', () => {
|
|
88
|
+
expect(output).not.toMatch(/\bctx\b/);
|
|
89
|
+
expect(output).not.toContain('ServerKitRouter');
|
|
90
|
+
expect(output).not.toContain('@maroonedsoftware/koa');
|
|
91
|
+
expect(output).not.toContain('requirePolicy');
|
|
92
|
+
expect(output).not.toContain('bodyParserMiddleware');
|
|
93
|
+
expect(output).not.toContain('requireSignature');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('renders the router shell through the adapter', () => {
|
|
97
|
+
expect(output).toContain('export const UsersRouter = StubRouter();');
|
|
98
|
+
expect(output).toContain("import { StubRouter } from '@stub/http';");
|
|
99
|
+
expect(output).toContain('}, END);');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('renders the route line, its path params and its middleware through the adapter', () => {
|
|
103
|
+
expect(output).toContain("UsersRouter.route('post', '/payments/<paymentId>', [stubPolicy(), stubBody('json'), stubSignature('stripe')], async (rq, rs) => {");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('reads params, query, headers and the body through the adapter', () => {
|
|
107
|
+
expect(output).toContain('rq.pathParams');
|
|
108
|
+
expect(output).toContain('rq.searchParams');
|
|
109
|
+
expect(output).toContain('rq.headerBag');
|
|
110
|
+
expect(output).toContain('rq.payload');
|
|
111
|
+
expect(output).toContain('switch (rq.mediaType) {');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('resolves services through the adapter', () => {
|
|
115
|
+
expect(output).toContain('rq.services.resolve(UsersService)');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('writes status, headers, content type and body through the adapter', () => {
|
|
119
|
+
expect(output).toContain('rs.setStatus(201);');
|
|
120
|
+
expect(output).toContain("rs.setMedia('application/json');");
|
|
121
|
+
expect(output).toContain('return rs.deliver(result);');
|
|
122
|
+
expect(output).toContain('rs.putHeader(\'etag\', String(result.headers["etag"]));');
|
|
123
|
+
expect(output).toContain('if (result.headers["retryAfter"] !== undefined) rs.putHeader(\'retry-after\', String(result.headers["retryAfter"]));');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('gives a bodyless response the adapter\'s terminal statement', () => {
|
|
127
|
+
// Koa needs none, so the generator only emits one because the adapter asked for it.
|
|
128
|
+
expect(output).toContain('rs.setStatus(204);');
|
|
129
|
+
expect(output).toContain('return rs.finish();');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('omits the status-case terminator when the adapter has none', () => {
|
|
133
|
+
expect(output).toContain('rs.setStatus(result.status);');
|
|
134
|
+
// Scoped to the status switch: the multi-MIME request dispatch is the generator's own control
|
|
135
|
+
// flow and keeps its `break;` whatever the framework is.
|
|
136
|
+
const statusSwitch = output.slice(output.indexOf('switch (result.status) {'));
|
|
137
|
+
expect(statusSwitch).toContain('case 202:');
|
|
138
|
+
expect(statusSwitch).not.toContain('break;');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -567,6 +567,35 @@ describe('generateOperation', () => {
|
|
|
567
567
|
expect(output).toContain('z.object({');
|
|
568
568
|
});
|
|
569
569
|
|
|
570
|
+
it('renders an inline array header through renderInputType, without the query coercion', () => {
|
|
571
|
+
// The comma-splitting preprocess belongs to query strings only. Headers and params share
|
|
572
|
+
// the same inline-param renderer, so the block has to be told which one it is rendering.
|
|
573
|
+
const root = opRoot([
|
|
574
|
+
opRoute('/users', [
|
|
575
|
+
opOperation('get', {
|
|
576
|
+
headers: [opParam('x-tags', arrayType(scalarType('string')))],
|
|
577
|
+
}),
|
|
578
|
+
]),
|
|
579
|
+
]);
|
|
580
|
+
const output = generateOp(root);
|
|
581
|
+
expect(output).toContain('ctx.headers');
|
|
582
|
+
expect(output).not.toContain('z.preprocess');
|
|
583
|
+
expect(output).not.toContain("v.split(',')");
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('declares headers as a whole object rather than destructuring it', () => {
|
|
587
|
+
const root = opRoot([
|
|
588
|
+
opRoute('/users', [
|
|
589
|
+
opOperation('get', {
|
|
590
|
+
headers: [opParam('authorization', scalarType('string'))],
|
|
591
|
+
}),
|
|
592
|
+
]),
|
|
593
|
+
]);
|
|
594
|
+
const output = generateOp(root);
|
|
595
|
+
expect(output).toContain('const headers = await parseAndValidate(');
|
|
596
|
+
expect(output).not.toContain('const { authorization }');
|
|
597
|
+
});
|
|
598
|
+
|
|
570
599
|
it('generates parseAndValidate import when operation has headers', () => {
|
|
571
600
|
const root = opRoot([
|
|
572
601
|
opRoute('/users', [
|
|
@@ -37,6 +37,13 @@ function inputs(opRoots = [opRoot([opRoute('/users', [opOperation('get')])], '/p
|
|
|
37
37
|
};
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** Inputs carrying an MCP-exposed operation, which is what makes the mcp sub-generator emit a router. */
|
|
41
|
+
function mcpInputs() {
|
|
42
|
+
return inputs([
|
|
43
|
+
opRoot([opRoute('/users', [opOperation('get', { mcp: true, responses: [opResponse(200, 'User', 'application/json')] })])], '/project/contracts/users.ck'),
|
|
44
|
+
]);
|
|
45
|
+
}
|
|
46
|
+
|
|
40
47
|
// ─── Tests ─────────────────────────────────────────────────────────────────
|
|
41
48
|
|
|
42
49
|
describe('createTypescriptPlugin (server)', () => {
|
|
@@ -224,6 +231,55 @@ describe('createTypescriptPlugin (server)', () => {
|
|
|
224
231
|
});
|
|
225
232
|
});
|
|
226
233
|
|
|
234
|
+
describe('framework', () => {
|
|
235
|
+
it('emits a Koa router when no framework is configured', async () => {
|
|
236
|
+
const plugin = createTypescriptPlugin({ server: {} }, '/project');
|
|
237
|
+
const ctx = makeCtx('/project');
|
|
238
|
+
await plugin.generateTargets!(inputs(), ctx);
|
|
239
|
+
const [content] = [...ctx.emitted.values()];
|
|
240
|
+
expect(content).toContain("from '@maroonedsoftware/koa'");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('emits byte-identical output when koa is named explicitly', async () => {
|
|
244
|
+
const runWith = async (server: Record<string, unknown>) => {
|
|
245
|
+
const ctx = makeCtx('/project');
|
|
246
|
+
await createTypescriptPlugin({ server }, '/project').generateTargets!(inputs(), ctx);
|
|
247
|
+
return [...ctx.emitted.values()].join('\n');
|
|
248
|
+
};
|
|
249
|
+
expect(await runWith({ framework: 'koa' })).toEqual(await runWith({}));
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('rejects a framework that has no adapter', async () => {
|
|
253
|
+
const plugin = createTypescriptPlugin({ server: { framework: 'express' as never } }, '/project');
|
|
254
|
+
await expect(plugin.generateTargets!(inputs(), makeCtx('/project'))).rejects.toThrow(
|
|
255
|
+
/server\.framework 'express' is not supported — expected one of: koa/,
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('rejects the unknown framework before any other config complaint', async () => {
|
|
260
|
+
// Both rules are broken here; the framework is the one reported, since a router for a
|
|
261
|
+
// framework that does not exist cannot be generated whatever the other settings say.
|
|
262
|
+
const plugin = createTypescriptPlugin({ server: { framework: 'express' as never, validateResponses: true } }, '/project');
|
|
263
|
+
await expect(plugin.generateTargets!(inputs(), makeCtx('/project'))).rejects.toThrow(/server\.framework/);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('renders mcp.router.ts for the configured framework', async () => {
|
|
267
|
+
const plugin = createTypescriptPlugin({ server: { framework: 'koa' }, mcp: {} }, '/project');
|
|
268
|
+
const ctx = makeCtx('/project');
|
|
269
|
+
await plugin.generateTargets!(mcpInputs(), ctx);
|
|
270
|
+
const router = [...ctx.emitted.entries()].find(([p]) => p.endsWith('mcp.router.ts'))?.[1];
|
|
271
|
+
expect(router).toContain("from '@maroonedsoftware/koa'");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('still emits a Koa mcp.router.ts when there is no server sub-config', async () => {
|
|
275
|
+
const plugin = createTypescriptPlugin({ mcp: {} }, '/project');
|
|
276
|
+
const ctx = makeCtx('/project');
|
|
277
|
+
await plugin.generateTargets!(mcpInputs(), ctx);
|
|
278
|
+
const router = [...ctx.emitted.entries()].find(([p]) => p.endsWith('mcp.router.ts'))?.[1];
|
|
279
|
+
expect(router).toContain("from '@maroonedsoftware/koa'");
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
227
283
|
describe('validateResponses', () => {
|
|
228
284
|
const userRoot = () =>
|
|
229
285
|
opRoot(
|