@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
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ServerFramework } from './server-framework.js';
|
|
2
|
+
/**
|
|
3
|
+
* ServerKit on Koa: the router is a `@koa/router` instance, handlers take a single `ctx`, and a
|
|
4
|
+
* response is written by assigning to `ctx.status` / `ctx.type` / `ctx.body` rather than returned.
|
|
5
|
+
*/
|
|
6
|
+
export declare const KOA_SERVER_FRAMEWORK: ServerFramework;
|
|
7
|
+
//# sourceMappingURL=server-framework-koa.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server-framework-koa.d.ts","sourceRoot":"","sources":["../src/server-framework-koa.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAW7D;;;GAGG;AACH,eAAO,MAAM,oBAAoB,EAAE,eAgGlC,CAAC"}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP frameworks the server sub-generator can target. Adding a name here without adding an adapter
|
|
3
|
+
* to {@link SERVER_FRAMEWORKS} fails to compile, which is the point of keeping the two in step.
|
|
4
|
+
*/
|
|
5
|
+
export declare const SERVER_FRAMEWORK_NAMES: readonly ["koa"];
|
|
6
|
+
/** One of {@link SERVER_FRAMEWORK_NAMES}. */
|
|
7
|
+
export type ServerFrameworkName = (typeof SERVER_FRAMEWORK_NAMES)[number];
|
|
8
|
+
/** The framework assumed when a config names none. */
|
|
9
|
+
export declare const DEFAULT_SERVER_FRAMEWORK_NAME: ServerFrameworkName;
|
|
10
|
+
/**
|
|
11
|
+
* Every framework-specific string the router and MCP router generators emit.
|
|
12
|
+
*
|
|
13
|
+
* Granularity is one statement (or one fragment) per method, so the shared codegen keeps ownership
|
|
14
|
+
* of control flow — which branches exist, what order they run in, and which values reach them — and
|
|
15
|
+
* an adapter only decides how a given step is spelled. Anything an adapter cannot express as a
|
|
16
|
+
* statement, such as ending a response, is returned as a list of lines so it can also be empty.
|
|
17
|
+
*/
|
|
18
|
+
export interface ServerFramework {
|
|
19
|
+
readonly name: ServerFrameworkName;
|
|
20
|
+
/**
|
|
21
|
+
* Import lines for the framework runtime, already filtered down to what the generated body uses.
|
|
22
|
+
*
|
|
23
|
+
* The adapter applies `uses` itself rather than declaring a symbol list, because a framework may
|
|
24
|
+
* need more than one import line, and because only names the adapter chooses ever go through the
|
|
25
|
+
* word-boundary probe — a handler-local identifier can never be mistaken for an import.
|
|
26
|
+
*/
|
|
27
|
+
imports(uses: (symbol: string) => boolean): string[];
|
|
28
|
+
/** The module-level router value every handler attaches to. */
|
|
29
|
+
routerDeclaration(routerName: string): string;
|
|
30
|
+
/** Placeholder syntax for one path parameter, given a name already mapped to a valid identifier. */
|
|
31
|
+
pathParam(identifier: string): string;
|
|
32
|
+
/** Opening line of a handler, including its middleware and the handler function's parameters. */
|
|
33
|
+
routeOpen(routerName: string, method: string, path: string, middlewares: readonly string[]): string;
|
|
34
|
+
/** Lines that close a handler opened by {@link routeOpen}. */
|
|
35
|
+
routeClose(): string[];
|
|
36
|
+
/** Route middleware factory calls, rendered as expressions for {@link routeOpen}. */
|
|
37
|
+
readonly middleware: {
|
|
38
|
+
policy(args: string): string;
|
|
39
|
+
bodyParser(tokensExpr: string): string;
|
|
40
|
+
signature(args: string): string;
|
|
41
|
+
};
|
|
42
|
+
/** Expressions a handler reads the request through. */
|
|
43
|
+
readonly request: {
|
|
44
|
+
params: string;
|
|
45
|
+
query: string;
|
|
46
|
+
headers: string;
|
|
47
|
+
/** The body already parsed by the body-parser middleware. */
|
|
48
|
+
parsedBody: string;
|
|
49
|
+
/**
|
|
50
|
+
* The request's media type with any parameters stripped. It is matched against declared MIME
|
|
51
|
+
* literals, so an adapter whose framework exposes only the raw header must normalise it here
|
|
52
|
+
* — a `; charset=utf-8` left on the end matches nothing.
|
|
53
|
+
*/
|
|
54
|
+
contentType: string;
|
|
55
|
+
};
|
|
56
|
+
/** Expression resolving a service class out of the request-scoped DI container. */
|
|
57
|
+
resolveService(className: string): string;
|
|
58
|
+
/** Statements a handler writes the response with. */
|
|
59
|
+
readonly response: {
|
|
60
|
+
status(expr: string): string;
|
|
61
|
+
/**
|
|
62
|
+
* One statement setting a response header. It is emitted bare or behind an `if` guard for an
|
|
63
|
+
* optional header, so it must stay a single statement.
|
|
64
|
+
*/
|
|
65
|
+
header(name: string, valueExpr: string): string;
|
|
66
|
+
type(expr: string): string;
|
|
67
|
+
/**
|
|
68
|
+
* The terminal write for a response, or for one without a body when `bodyExpr` is undefined.
|
|
69
|
+
* A framework that ends a response by returning needs a statement in both cases; Koa, which
|
|
70
|
+
* ends it by assignment, emits nothing for a bodyless one.
|
|
71
|
+
*/
|
|
72
|
+
send(bodyExpr: string | undefined): string[];
|
|
73
|
+
/** What closes one `case` of the multi-status switch, after that status has been written. */
|
|
74
|
+
caseEnd(): string[];
|
|
75
|
+
};
|
|
76
|
+
/** The whole `mcp.router.ts` file, which is boilerplate rather than a per-operation render. */
|
|
77
|
+
mcpRouter(options: {
|
|
78
|
+
path: string;
|
|
79
|
+
}): string;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Every supported framework, keyed by name. The annotation is what ties this to
|
|
83
|
+
* {@link SERVER_FRAMEWORK_NAMES}: adding a name without an adapter is a compile error.
|
|
84
|
+
*/
|
|
85
|
+
export declare const SERVER_FRAMEWORKS: Readonly<Record<ServerFrameworkName, ServerFramework>>;
|
|
86
|
+
/**
|
|
87
|
+
* Resolve a configured framework name to its adapter.
|
|
88
|
+
*
|
|
89
|
+
* @param name The `server.framework` value, or undefined for {@link DEFAULT_SERVER_FRAMEWORK_NAME}.
|
|
90
|
+
* @throws When `name` is not a supported framework. Config arrives as JSON, so this is a runtime
|
|
91
|
+
* check and not something the `ServerFrameworkName` type can enforce on its own.
|
|
92
|
+
*/
|
|
93
|
+
export declare function resolveServerFramework(name: string | undefined): ServerFramework;
|
|
94
|
+
//# sourceMappingURL=server-framework.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server-framework.d.ts","sourceRoot":"","sources":["../src/server-framework.ts"],"names":[],"mappings":"AAEA;;;GAGG;AACH,eAAO,MAAM,sBAAsB,kBAAmB,CAAC;AAEvD,6CAA6C;AAC7C,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,sBAAsB,CAAC,CAAC,MAAM,CAAC,CAAC;AAE1E,sDAAsD;AACtD,eAAO,MAAM,6BAA6B,EAAE,mBAA2B,CAAC;AAExE;;;;;;;GAOG;AACH,MAAM,WAAW,eAAe;IAC5B,QAAQ,CAAC,IAAI,EAAE,mBAAmB,CAAC;IAEnC;;;;;;OAMG;IACH,OAAO,CAAC,IAAI,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,GAAG,MAAM,EAAE,CAAC;IAErD,+DAA+D;IAC/D,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC;IAE9C,oGAAoG;IACpG,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC;IAEtC,iGAAiG;IACjG,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAAC;IAEpG,8DAA8D;IAC9D,UAAU,IAAI,MAAM,EAAE,CAAC;IAEvB,qFAAqF;IACrF,QAAQ,CAAC,UAAU,EAAE;QACjB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;QAC7B,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAAC;QACvC,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;KACnC,CAAC;IAEF,uDAAuD;IACvD,QAAQ,CAAC,OAAO,EAAE;QACd,MAAM,EAAE,MAAM,CAAC;QACf,KAAK,EAAE,MAAM,CAAC;QACd,OAAO,EAAE,MAAM,CAAC;QAChB,6DAA6D;QAC7D,UAAU,EAAE,MAAM,CAAC;QACnB;;;;WAIG;QACH,WAAW,EAAE,MAAM,CAAC;KACvB,CAAC;IAEF,mFAAmF;IACnF,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC;IAE1C,qDAAqD;IACrD,QAAQ,CAAC,QAAQ,EAAE;QACf,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;QAC7B;;;WAGG;QACH,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC;QAChD,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;QAC3B;;;;WAIG;QACH,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAAC;QAC7C,6FAA6F;QAC7F,OAAO,IAAI,MAAM,EAAE,CAAC;KACvB,CAAC;IAEF,+FAA+F;IAC/F,SAAS,CAAC,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM,CAAC;CAChD;AAED;;;GAGG;AACH,eAAO,MAAM,iBAAiB,EAAE,QAAQ,CAAC,MAAM,CAAC,mBAAmB,EAAE,eAAe,CAAC,CAEpF,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,eAAe,CAShF"}
|
package/llms.txt
CHANGED
|
@@ -56,7 +56,7 @@ Path templates accept `{filename}`, `{dir}`, `{area}`, and — in `sdk.output.sd
|
|
|
56
56
|
|
|
57
57
|
| Key | Emits |
|
|
58
58
|
| --- | --- |
|
|
59
|
-
| `server` |
|
|
59
|
+
| `server` | Server routers from `operation` declarations, plus the type or Zod files they import. `framework` selects the HTTP framework; only `koa` today |
|
|
60
60
|
| `sdk` | The SDK class, per-area operation clients, and their types |
|
|
61
61
|
| `zod` | Standalone Zod schemas, independent of `server` and `sdk` |
|
|
62
62
|
| `types` | Standalone plain TypeScript types, independent of `server` and `sdk` |
|
package/package.json
CHANGED
package/src/codegen-mcp.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { inferService, deriveModulePath, buildArgs, deriveBaseName } from './cod
|
|
|
5
5
|
import { quoteKey, escapeSingleQuoted, sourceLink } from './ts-render.js';
|
|
6
6
|
import { DECIMAL_IMPORT, DECIMAL_PRELUDE_LINES } from './decimal-runtime.js';
|
|
7
7
|
import { basename, dirname, relative } from 'node:path';
|
|
8
|
+
import type { ServerFramework } from './server-framework.js';
|
|
9
|
+
import { KOA_SERVER_FRAMEWORK } from './server-framework-koa.js';
|
|
8
10
|
|
|
9
11
|
// ─── Options ────────────────────────────────────────────────────────────────
|
|
10
12
|
|
|
@@ -498,29 +500,13 @@ export function generateMcpAggregator(entries: McpAggregatorEntry[]): string {
|
|
|
498
500
|
return lines.join('\n') + '\n';
|
|
499
501
|
}
|
|
500
502
|
|
|
501
|
-
/**
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
const dispatcher = ctx.container.get(McpDispatcher);
|
|
511
|
-
const context = createMcpRequestContext({ requestId: ctx.requestId, logger: ctx.logger });
|
|
512
|
-
if (dispatcher.sessionMode === 'stateful') {
|
|
513
|
-
ctx.respond = false;
|
|
514
|
-
await dispatcher.dispatchStateful(
|
|
515
|
-
{ req: ctx.req, res: ctx.res, body: ctx.parsedBody, sessionId: ctx.get('mcp-session-id') },
|
|
516
|
-
context,
|
|
517
|
-
);
|
|
518
|
-
} else {
|
|
519
|
-
const response = await dispatcher.dispatch(JSON.parse(String(ctx.rawBody)), context);
|
|
520
|
-
if (response) ctx.body = response;
|
|
521
|
-
else ctx.status = 202; // a notification — nothing to return
|
|
522
|
-
}
|
|
523
|
-
});
|
|
524
|
-
}
|
|
525
|
-
`;
|
|
503
|
+
/**
|
|
504
|
+
* Generate the optional `mcp.router.ts` — the standard ServerKit route wiring for the dispatcher.
|
|
505
|
+
*
|
|
506
|
+
* The whole file is framework-specific boilerplate rather than a per-operation render, so the
|
|
507
|
+
* adapter owns the template. Defaults to Koa, matching the router generator.
|
|
508
|
+
*/
|
|
509
|
+
export function generateMcpRouter(options: { path?: string; framework?: ServerFramework } = {}): string {
|
|
510
|
+
const framework = options.framework ?? KOA_SERVER_FRAMEWORK;
|
|
511
|
+
return framework.mcpRouter({ path: options.path ?? '/mcp' });
|
|
526
512
|
}
|
package/src/codegen-operation.ts
CHANGED
|
@@ -21,10 +21,19 @@ import {
|
|
|
21
21
|
import { renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, escapeSingleQuoted, sourceLink } from './ts-render.js';
|
|
22
22
|
import { DECIMAL_IMPORT, DECIMAL_PRELUDE_LINES } from './decimal-runtime.js';
|
|
23
23
|
import { basename, dirname, relative } from 'path';
|
|
24
|
+
import type { ServerFramework } from './server-framework.js';
|
|
25
|
+
import { KOA_SERVER_FRAMEWORK } from './server-framework-koa.js';
|
|
26
|
+
|
|
27
|
+
/** Which request-side object a validation block reads from. Names the variable the block declares. */
|
|
28
|
+
export type ParamKind = 'params' | 'query' | 'headers';
|
|
24
29
|
|
|
25
30
|
// ─── Content-type helpers ──────────────────────────────────────────────────
|
|
26
31
|
|
|
27
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Map a request MIME type to the ServerKit parser token used in middleware. The tokens are the keys
|
|
34
|
+
* of the parser map in `@maroonedsoftware/servercore`, so they are the same whichever HTTP framework
|
|
35
|
+
* the router targets.
|
|
36
|
+
*/
|
|
28
37
|
function bodyParserToken(contentType: string): string {
|
|
29
38
|
switch (classifyContentType(contentType)) {
|
|
30
39
|
case 'urlencoded':
|
|
@@ -34,9 +43,8 @@ function bodyParserToken(contentType: string): string {
|
|
|
34
43
|
case 'text':
|
|
35
44
|
return 'text';
|
|
36
45
|
case 'binary':
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
// multipart/form-data.
|
|
46
|
+
// There is no native binary token; fall back to text so the body is still readable as a
|
|
47
|
+
// string. Services handling binary uploads should switch to multipart/form-data.
|
|
40
48
|
return 'text';
|
|
41
49
|
default:
|
|
42
50
|
return 'json';
|
|
@@ -125,7 +133,7 @@ export function bodyTypesStructurallyEqual(a: ContractTypeNode, b: ContractTypeN
|
|
|
125
133
|
|
|
126
134
|
// ─── Public entry point ────────────────────────────────────────────────────
|
|
127
135
|
|
|
128
|
-
/** Options controlling how {@link generateOp} renders a
|
|
136
|
+
/** Options controlling how {@link generateOp} renders a server router module. */
|
|
129
137
|
export interface OpCodegenOptions {
|
|
130
138
|
servicePathTemplate?: string;
|
|
131
139
|
typeImportPathTemplate?: string;
|
|
@@ -143,8 +151,8 @@ export interface OpCodegenOptions {
|
|
|
143
151
|
*/
|
|
144
152
|
includeInternal?: boolean;
|
|
145
153
|
/**
|
|
146
|
-
* Re-parse the service result through its declared response schema before writing
|
|
147
|
-
* and write the parsed value. Requires the type file to hold Zod schemas (`server.zod`) —
|
|
154
|
+
* Re-parse the service result through its declared response schema before writing the response
|
|
155
|
+
* body, and write the parsed value. Requires the type file to hold Zod schemas (`server.zod`) —
|
|
148
156
|
* plain interfaces are types, with no runtime schema value to validate against. Default false.
|
|
149
157
|
*/
|
|
150
158
|
validateResponses?: boolean;
|
|
@@ -154,10 +162,18 @@ export interface OpCodegenOptions {
|
|
|
154
162
|
* post-transform shape, which the schema itself cannot re-parse.
|
|
155
163
|
*/
|
|
156
164
|
modelsWithTransform?: Set<string>;
|
|
165
|
+
/**
|
|
166
|
+
* Which HTTP framework the emitted router targets. Every framework-specific string in the output
|
|
167
|
+
* comes from here. Defaults to Koa, the only framework shipped today.
|
|
168
|
+
*/
|
|
169
|
+
framework?: ServerFramework;
|
|
157
170
|
}
|
|
158
171
|
|
|
172
|
+
/** {@link OpCodegenOptions} after {@link generateOp} has filled in the framework default. */
|
|
173
|
+
type ResolvedOpCodegenOptions = OpCodegenOptions & { framework: ServerFramework };
|
|
174
|
+
|
|
159
175
|
/**
|
|
160
|
-
* Generate a
|
|
176
|
+
* Generate a server router module for every operation in `root`, including the imports, type
|
|
161
177
|
* aliases, and handler list.
|
|
162
178
|
*
|
|
163
179
|
* Imports are derived from the generated body — each candidate symbol is emitted only if it
|
|
@@ -166,6 +182,10 @@ export interface OpCodegenOptions {
|
|
|
166
182
|
* trips `noUnusedLocals` and lint downstream.
|
|
167
183
|
*/
|
|
168
184
|
export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): string {
|
|
185
|
+
// Resolved once here rather than defaulted at each use, so every helper below reads a framework
|
|
186
|
+
// that is definitely present and no branch can quietly fall back to a different one.
|
|
187
|
+
const resolved: ResolvedOpCodegenOptions = { ...options, framework: options.framework ?? KOA_SERVER_FRAMEWORK };
|
|
188
|
+
const framework = resolved.framework;
|
|
169
189
|
// Collect all referenced types across all routes
|
|
170
190
|
const types = collectTypes(root, options.modelsWithInput, options.modelsWithOutput);
|
|
171
191
|
const services = collectServices(root);
|
|
@@ -177,14 +197,14 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
|
|
|
177
197
|
lines.push('/**');
|
|
178
198
|
lines.push(` * generated from ${sourceLink(basename(root.file), options.outPath, root.file)}`);
|
|
179
199
|
lines.push('*/');
|
|
180
|
-
lines.push(
|
|
200
|
+
lines.push(framework.routerDeclaration(routerName));
|
|
181
201
|
lines.push('');
|
|
182
202
|
|
|
183
203
|
const includeInternal = options.includeInternal ?? true;
|
|
184
204
|
for (const route of root.routes) {
|
|
185
205
|
for (const op of route.operations) {
|
|
186
206
|
if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
|
|
187
|
-
lines.push(...generateHandler(route, op, root,
|
|
207
|
+
lines.push(...generateHandler(route, op, root, resolved));
|
|
188
208
|
lines.push('');
|
|
189
209
|
}
|
|
190
210
|
}
|
|
@@ -231,10 +251,7 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
|
|
|
231
251
|
|
|
232
252
|
const body: string[] = [];
|
|
233
253
|
|
|
234
|
-
|
|
235
|
-
if (koaImports.length > 0) {
|
|
236
|
-
body.push(`import { ${koaImports.join(', ')} } from '@maroonedsoftware/koa';`);
|
|
237
|
-
}
|
|
254
|
+
body.push(...framework.imports(uses));
|
|
238
255
|
|
|
239
256
|
// Services and model names come from the AST, which over-approximates two ways: a model with an
|
|
240
257
|
// Input/Output variant contributes its base name even when only the variant is ever annotated,
|
|
@@ -280,11 +297,12 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
|
|
|
280
297
|
|
|
281
298
|
// ─── Handler generation ────────────────────────────────────────────────────
|
|
282
299
|
|
|
283
|
-
function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNode, options:
|
|
300
|
+
function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNode, options: ResolvedOpCodegenOptions): string[] {
|
|
284
301
|
const lines: string[] = [];
|
|
285
302
|
const file = root.file;
|
|
286
303
|
const outPath = options.outPath;
|
|
287
304
|
const modelsWithInput = options.modelsWithInput;
|
|
305
|
+
const framework = options.framework;
|
|
288
306
|
|
|
289
307
|
lines.push('/**');
|
|
290
308
|
|
|
@@ -310,10 +328,10 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
310
328
|
lines.push('*/');
|
|
311
329
|
|
|
312
330
|
const method = op.method;
|
|
313
|
-
//
|
|
314
|
-
// a path placeholder's name never reaches the wire —
|
|
315
|
-
// is free, and it is what lets
|
|
316
|
-
const path = route.path.replace(PATH_PARAM_RE_G, (_m, name: string) =>
|
|
331
|
+
// The framework's placeholder syntax, from `{name}`, mapped to a valid identifier. Unlike a query
|
|
332
|
+
// parameter or a header, a path placeholder's name never reaches the wire — the framework matches
|
|
333
|
+
// by position — so renaming it is free, and it is what lets the params object be destructured.
|
|
334
|
+
const path = route.path.replace(PATH_PARAM_RE_G, (_m, name: string) => framework.pathParam(toIdentifier(name)));
|
|
317
335
|
const bodies = op.request?.bodies ?? [];
|
|
318
336
|
const hasBody = bodies.length > 0;
|
|
319
337
|
const isSingleMultipart = bodies.length === 1 && bodies[0]!.contentType === 'multipart/form-data';
|
|
@@ -328,39 +346,37 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
328
346
|
: policy === false
|
|
329
347
|
? '{ policy: false }'
|
|
330
348
|
: `{ policy: '${policy}' }`;
|
|
331
|
-
middlewares.push(
|
|
349
|
+
middlewares.push(framework.middleware.policy(args));
|
|
332
350
|
}
|
|
333
351
|
if (hasBody) {
|
|
334
352
|
const parserTokens = Array.from(new Set(bodies.map(b => bodyParserToken(b.contentType))));
|
|
335
353
|
const tokensExpr = parserTokens.map(t => `'${t}'`).join(', ');
|
|
336
|
-
middlewares.push(
|
|
354
|
+
middlewares.push(framework.middleware.bodyParser(tokensExpr));
|
|
337
355
|
}
|
|
338
356
|
if (op.signature) {
|
|
339
357
|
const sigArgs = op.signaturePolicy
|
|
340
358
|
? `'${escapeSingleQuoted(op.signature)}', { policy: '${escapeSingleQuoted(op.signaturePolicy)}' }`
|
|
341
359
|
: `'${escapeSingleQuoted(op.signature)}'`;
|
|
342
|
-
middlewares.push(
|
|
360
|
+
middlewares.push(framework.middleware.signature(sigArgs));
|
|
343
361
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
lines.push(`${deriveRouterName(file)}.${method}('${path}'${middlewareStr} async ctx => {`);
|
|
362
|
+
lines.push(framework.routeOpen(deriveRouterName(file), method, path, middlewares));
|
|
347
363
|
|
|
348
364
|
// Params / query / headers validation (request-side — use Input variants)
|
|
349
|
-
lines.push(...generateParamValidation(route.params, '
|
|
350
|
-
lines.push(...generateParamValidation(op.query, '
|
|
351
|
-
lines.push(...generateParamValidation(op.headers, '
|
|
365
|
+
lines.push(...generateParamValidation(route.params, 'params', framework.request.params, route.paramsMode ?? 'strict', '', modelsWithInput));
|
|
366
|
+
lines.push(...generateParamValidation(op.query, 'query', framework.request.query, op.queryMode ?? 'strict', '', modelsWithInput));
|
|
367
|
+
lines.push(...generateParamValidation(op.headers, 'headers', framework.request.headers, op.headersMode ?? 'strip', '', modelsWithInput));
|
|
352
368
|
|
|
353
369
|
// Body validation (request-side — use Input variants)
|
|
354
370
|
if (hasBody && op.request) {
|
|
355
371
|
if (isSingleMultipart) {
|
|
356
|
-
lines.push(` const multipartBody =
|
|
372
|
+
lines.push(` const multipartBody = ${framework.request.parsedBody} as MultipartBody;`);
|
|
357
373
|
lines.push('');
|
|
358
374
|
} else if (bodies.length === 1) {
|
|
359
|
-
lines.push(` const body = await parseAndValidate(
|
|
375
|
+
lines.push(` const body = await parseAndValidate(${framework.request.parsedBody}, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
|
|
360
376
|
lines.push('');
|
|
361
377
|
} else if (bodies.every(b => bodyTypesStructurallyEqual(b.bodyType, bodies[0]!.bodyType))) {
|
|
362
378
|
// All declared MIMEs share the same body shape — single validation suffices
|
|
363
|
-
lines.push(` const body = await parseAndValidate(
|
|
379
|
+
lines.push(` const body = await parseAndValidate(${framework.request.parsedBody}, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
|
|
364
380
|
lines.push('');
|
|
365
381
|
} else {
|
|
366
382
|
// Different body types per MIME — dispatch on Content-Type
|
|
@@ -370,13 +386,13 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
370
386
|
)
|
|
371
387
|
.join(' | ');
|
|
372
388
|
lines.push(` let body!: ${annotation};`);
|
|
373
|
-
lines.push(` switch (
|
|
389
|
+
lines.push(` switch (${framework.request.contentType}) {`);
|
|
374
390
|
for (const b of bodies) {
|
|
375
391
|
lines.push(` case '${b.contentType}':`);
|
|
376
392
|
if (b.contentType === 'multipart/form-data') {
|
|
377
|
-
lines.push(` body =
|
|
393
|
+
lines.push(` body = ${framework.request.parsedBody} as MultipartBody;`);
|
|
378
394
|
} else {
|
|
379
|
-
lines.push(` body = await parseAndValidate(
|
|
395
|
+
lines.push(` body = await parseAndValidate(${framework.request.parsedBody}, ${renderInputType(b.bodyType, modelsWithInput)});`);
|
|
380
396
|
}
|
|
381
397
|
lines.push(` break;`);
|
|
382
398
|
}
|
|
@@ -397,25 +413,26 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
397
413
|
lines.push(...generateSingleStatusResult(emitted[0], op, serviceParts.className, call, options));
|
|
398
414
|
}
|
|
399
415
|
|
|
400
|
-
lines.push(
|
|
416
|
+
lines.push(...framework.routeClose());
|
|
401
417
|
|
|
402
418
|
return lines;
|
|
403
419
|
}
|
|
404
420
|
|
|
405
421
|
/**
|
|
406
422
|
* The service produces exactly one status (or none): the result is the body itself, or
|
|
407
|
-
* `{ body, headers }` when the status declares headers, and
|
|
423
|
+
* `{ body, headers }` when the status declares headers, and the status code is a constant.
|
|
408
424
|
*
|
|
409
425
|
* A status declaring several mimes also gains a `contentType` the service picks, which is the
|
|
410
|
-
* only thing here that can turn
|
|
426
|
+
* only thing here that can turn the response content type from a literal into an expression.
|
|
411
427
|
*/
|
|
412
428
|
function generateSingleStatusResult(
|
|
413
429
|
resp: OpResponseNode | undefined,
|
|
414
430
|
op: OpOperationNode,
|
|
415
431
|
className: string,
|
|
416
432
|
call: string,
|
|
417
|
-
options:
|
|
433
|
+
options: ResolvedOpCodegenOptions,
|
|
418
434
|
): string[] {
|
|
435
|
+
const framework = options.framework;
|
|
419
436
|
const lines: string[] = [];
|
|
420
437
|
const bodies = resp ? resp.bodies : [];
|
|
421
438
|
const respHeaders = resp?.headers ?? [];
|
|
@@ -428,7 +445,7 @@ function generateSingleStatusResult(
|
|
|
428
445
|
const { annotation, prelude } = formatTypeAnnotation(bodies[0]!.bodyType, options.modelsWithOutput);
|
|
429
446
|
if (prelude) lines.push(` ${prelude}`);
|
|
430
447
|
bodySchema = responseBodySchema(bodies[0]!.bodyType, options, prelude ? 'resultType' : undefined);
|
|
431
|
-
lines.push(` const service =
|
|
448
|
+
lines.push(` const service = ${framework.resolveService(className)};`);
|
|
432
449
|
if (hasRespHeaders) {
|
|
433
450
|
lines.push(` const result: { body: ${annotation}; headers: ${headersAnnotation} } = ${call};`);
|
|
434
451
|
} else {
|
|
@@ -439,10 +456,10 @@ function generateSingleStatusResult(
|
|
|
439
456
|
const { members, preludes } = rendered;
|
|
440
457
|
bodySchema = rendered.bodySchema;
|
|
441
458
|
for (const prelude of preludes) lines.push(` ${prelude}`);
|
|
442
|
-
lines.push(` const service =
|
|
459
|
+
lines.push(` const service = ${framework.resolveService(className)};`);
|
|
443
460
|
lines.push(` const result: ${members.join(' | ')} = ${call};`);
|
|
444
461
|
} else {
|
|
445
|
-
lines.push(` const service =
|
|
462
|
+
lines.push(` const service = ${framework.resolveService(className)};`);
|
|
446
463
|
if (hasRespHeaders) {
|
|
447
464
|
lines.push(` const result: { headers: ${headersAnnotation} } = ${call};`);
|
|
448
465
|
} else {
|
|
@@ -456,15 +473,18 @@ function generateSingleStatusResult(
|
|
|
456
473
|
// also what aligns the three generators: `observableResponses` excludes a bare `400:` too, so
|
|
457
474
|
// the SDK already types such a method `Promise<void>` and `thrownResponses` puts the 400 in
|
|
458
475
|
// `@throws`. A bodyless 204 success is exactly what `Promise<void>` means.
|
|
459
|
-
lines.push(`
|
|
460
|
-
lines.push(...headerSetLines(respHeaders, ' '));
|
|
476
|
+
lines.push(` ${framework.response.status(String(resp?.statusCode ?? 204))}`);
|
|
477
|
+
lines.push(...headerSetLines(respHeaders, ' ', framework));
|
|
461
478
|
|
|
462
479
|
if (bodies.length === 1) {
|
|
463
|
-
lines.push(`
|
|
464
|
-
lines.push(
|
|
480
|
+
lines.push(` ${framework.response.type(`'${bodies[0]!.contentType}'`)}`);
|
|
481
|
+
lines.push(...indent(framework.response.send(responseBodyExpr(hasRespHeaders ? 'result.body' : 'result', bodySchema)), ' '));
|
|
465
482
|
} else if (bodies.length > 1) {
|
|
466
|
-
lines.push(`
|
|
467
|
-
lines.push(
|
|
483
|
+
lines.push(` ${framework.response.type('result.contentType')}`);
|
|
484
|
+
lines.push(...indent(framework.response.send(responseBodyExpr('result.body', bodySchema)), ' '));
|
|
485
|
+
} else {
|
|
486
|
+
// Nothing to write, but a framework that ends a response by returning still needs a statement.
|
|
487
|
+
lines.push(...indent(framework.response.send(undefined), ' '));
|
|
468
488
|
}
|
|
469
489
|
|
|
470
490
|
return lines;
|
|
@@ -475,7 +495,8 @@ function generateSingleStatusResult(
|
|
|
475
495
|
* `status`, and the handler switches on it so each status writes only its own headers, mime
|
|
476
496
|
* and body.
|
|
477
497
|
*/
|
|
478
|
-
function generateMultiStatusResult(emitted: OpResponseNode[], className: string, call: string, options:
|
|
498
|
+
function generateMultiStatusResult(emitted: OpResponseNode[], className: string, call: string, options: ResolvedOpCodegenOptions): string[] {
|
|
499
|
+
const framework = options.framework;
|
|
479
500
|
const lines: string[] = [];
|
|
480
501
|
const members: string[] = [];
|
|
481
502
|
const preludes: string[] = [];
|
|
@@ -490,21 +511,23 @@ function generateMultiStatusResult(emitted: OpResponseNode[], className: string,
|
|
|
490
511
|
}
|
|
491
512
|
|
|
492
513
|
for (const prelude of preludes) lines.push(` ${prelude}`);
|
|
493
|
-
lines.push(` const service =
|
|
514
|
+
lines.push(` const service = ${framework.resolveService(className)};`);
|
|
494
515
|
lines.push(` const result:`);
|
|
495
516
|
for (const member of members) lines.push(` | ${member}`);
|
|
496
517
|
lines.push(` = ${call};`);
|
|
497
518
|
lines.push('');
|
|
498
|
-
lines.push(`
|
|
519
|
+
lines.push(` ${framework.response.status('result.status')}`);
|
|
499
520
|
lines.push(` switch (result.status) {`);
|
|
500
521
|
for (const resp of emitted) {
|
|
501
522
|
lines.push(` case ${resp.statusCode}:`);
|
|
502
|
-
lines.push(...headerSetLines(resp.headers ?? [], ' '));
|
|
523
|
+
lines.push(...headerSetLines(resp.headers ?? [], ' ', framework));
|
|
503
524
|
if (resp.bodies.length > 0) {
|
|
504
|
-
lines.push(`
|
|
505
|
-
lines.push(
|
|
525
|
+
lines.push(` ${framework.response.type('result.contentType')}`);
|
|
526
|
+
lines.push(...indent(framework.response.send(responseBodyExpr('result.body', bodySchemas.get(resp.statusCode))), ' '));
|
|
527
|
+
} else {
|
|
528
|
+
lines.push(...indent(framework.response.send(undefined), ' '));
|
|
506
529
|
}
|
|
507
|
-
lines.push(
|
|
530
|
+
lines.push(...indent(framework.response.caseEnd(), ' '));
|
|
508
531
|
}
|
|
509
532
|
lines.push(` }`);
|
|
510
533
|
|
|
@@ -565,16 +588,20 @@ function renderHeadersAnnotation(headers: OpResponseHeaderNode[], modelsWithOutp
|
|
|
565
588
|
return `{ ${fields.join('; ')} }`;
|
|
566
589
|
}
|
|
567
590
|
|
|
568
|
-
/**
|
|
569
|
-
function headerSetLines(headers: OpResponseHeaderNode[],
|
|
591
|
+
/** Response-header writes for a status's declared headers, guarding the optional ones. */
|
|
592
|
+
function headerSetLines(headers: OpResponseHeaderNode[], pad: string, framework: ServerFramework): string[] {
|
|
570
593
|
return headers.map(h => {
|
|
571
594
|
const accessor = `result.headers[${JSON.stringify(headerNameToProperty(h.name))}]`;
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
: `${indent}ctx.set('${h.name}', String(${accessor}));`;
|
|
595
|
+
const write = framework.response.header(h.name, `String(${accessor})`);
|
|
596
|
+
return h.optional ? `${pad}if (${accessor} !== undefined) ${write}` : `${pad}${write}`;
|
|
575
597
|
});
|
|
576
598
|
}
|
|
577
599
|
|
|
600
|
+
/** Prefix each of a framework's statements with the handler indentation the caller is writing at. */
|
|
601
|
+
function indent(lines: string[], pad: string): string[] {
|
|
602
|
+
return lines.map(line => `${pad}${line}`);
|
|
603
|
+
}
|
|
604
|
+
|
|
578
605
|
// ─── Inference helpers ─────────────────────────────────────────────────────
|
|
579
606
|
|
|
580
607
|
/**
|
|
@@ -778,7 +805,7 @@ function responseBodySchema(bodyType: ContractTypeNode, options: OpCodegenOption
|
|
|
778
805
|
}
|
|
779
806
|
|
|
780
807
|
/**
|
|
781
|
-
* The
|
|
808
|
+
* The right-hand side of a response body write: the raw result expression, or a
|
|
782
809
|
* `parseAndValidate` of it. The `500` is deliberate — a service returning a shape its own contract
|
|
783
810
|
* rejects is a server fault, not a client one, and `@maroonedsoftware/zod` routes the field-level
|
|
784
811
|
* detail to `internalDetails` (log-only) rather than the response body at 5xx.
|
|
@@ -789,37 +816,35 @@ function responseBodyExpr(value: string, schema: string | undefined): string {
|
|
|
789
816
|
|
|
790
817
|
function generateParamValidation(
|
|
791
818
|
source: ParamSource | undefined,
|
|
792
|
-
|
|
793
|
-
|
|
819
|
+
kind: ParamKind,
|
|
820
|
+
sourceExpr: string,
|
|
794
821
|
mode: ObjectMode,
|
|
795
822
|
suffix = '',
|
|
796
823
|
modelsWithInput?: Set<string>,
|
|
797
824
|
): string[] {
|
|
798
825
|
if (!source) return [];
|
|
799
826
|
const lines: string[] = [];
|
|
800
|
-
const isQuery =
|
|
827
|
+
const isQuery = kind === 'query';
|
|
828
|
+
// Path params are destructured and spread into the service call; query and headers pass as
|
|
829
|
+
// whole objects. The variable the block declares is named after the kind either way.
|
|
830
|
+
const isPathParams = kind === 'params';
|
|
801
831
|
if (source.kind === 'ref') {
|
|
802
832
|
// Type reference — apply mode as a method call on the schema
|
|
803
833
|
const typeName = modelsWithInput?.has(source.name) ? `${source.name}Input` : source.name;
|
|
804
|
-
lines.push(` const ${
|
|
834
|
+
lines.push(` const ${kind} = await parseAndValidate(${sourceExpr}, ${typeName}.${mode}());`);
|
|
805
835
|
lines.push('');
|
|
806
836
|
} else if (source.kind === 'params') {
|
|
807
837
|
// Inline param declarations — wrap with the appropriate z.*Object constructor
|
|
808
838
|
if (source.nodes.length > 0) {
|
|
809
|
-
// Destructure only for params (spread individually in service call);
|
|
810
|
-
// query/headers are passed as whole objects.
|
|
811
|
-
// Path params are destructured and spread into the service call; query and headers pass
|
|
812
|
-
// as whole objects.
|
|
813
|
-
const isPathParams = ctxExpr === 'ctx.params';
|
|
814
839
|
const bind = (name: string) => (isPathParams ? toIdentifier(name) : name);
|
|
815
|
-
const lhs =
|
|
840
|
+
const lhs = isPathParams ? `{ ${source.nodes.map(p => bind(p.name)).join(', ')} }` : kind;
|
|
816
841
|
lines.push(` const ${lhs} = await parseAndValidate(`);
|
|
817
|
-
lines.push(` ${
|
|
842
|
+
lines.push(` ${sourceExpr},`);
|
|
818
843
|
lines.push(` ${modeToWrapper(mode)}({`);
|
|
819
844
|
for (const param of source.nodes) {
|
|
820
845
|
// For path params the key must match the name in the route pattern above, which
|
|
821
|
-
// is what
|
|
822
|
-
// quoted when that is not an identifier — those the client actually sends.
|
|
846
|
+
// is what the framework keys its params object by. For query and headers it is the
|
|
847
|
+
// wire name, quoted when that is not an identifier — those the client actually sends.
|
|
823
848
|
const bound = bind(param.name);
|
|
824
849
|
const key = isValidIdentifier(bound) ? bound : `'${bound}'`;
|
|
825
850
|
// Delegating to renderQueryType rather than hand-rolling the array preprocess here:
|
|
@@ -837,7 +862,7 @@ function generateParamValidation(
|
|
|
837
862
|
// ContractTypeNode — use query-aware rendering for query params (coerces single string → array),
|
|
838
863
|
// otherwise use Input variant rendering; apply mode as a method call
|
|
839
864
|
const schema = isQuery ? renderQueryType(source.node, modelsWithInput) : renderInputType(source.node, modelsWithInput);
|
|
840
|
-
lines.push(` const ${
|
|
865
|
+
lines.push(` const ${kind} = await parseAndValidate(${sourceExpr}, (${schema}).${mode}());`);
|
|
841
866
|
lines.push('');
|
|
842
867
|
}
|
|
843
868
|
return lines;
|