@contractkit/plugin-typescript 0.29.0 → 0.31.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 +4 -4
- package/.turbo/turbo-test$colon$ci.log +19 -19
- package/CHANGELOG.md +35 -0
- package/README.md +8 -1
- package/dist/codegen-mcp.d.ts.map +1 -1
- package/dist/codegen-operation.d.ts +9 -1
- package/dist/codegen-operation.d.ts.map +1 -1
- package/dist/codegen-sdk.d.ts +8 -0
- package/dist/codegen-sdk.d.ts.map +1 -1
- package/dist/index.js +487 -168
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/codegen-mcp.ts +15 -7
- package/src/codegen-operation.ts +243 -167
- package/src/codegen-sdk.ts +274 -49
- package/src/index.ts +1 -1
- package/tests/codegen-operation.test.ts +288 -7
- package/tests/codegen-sdk.test.ts +184 -6
- package/tests/helpers.ts +20 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contractkit/plugin-typescript",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa routers, Zod schemas, plain types)",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Marooned Software",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
".": "./dist/index.js"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@contractkit/core": "0.
|
|
29
|
+
"@contractkit/core": "0.25.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@repo/config-eslint": "0.3.1",
|
package/src/codegen-mcp.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OpRootNode, OpRouteNode, OpOperationNode, McpConfigNode, ParamSource, ContractTypeNode } from '@contractkit/core';
|
|
2
|
-
import { resolveModifiers } from '@contractkit/core';
|
|
2
|
+
import { resolveModifiers, emittedResponses } from '@contractkit/core';
|
|
3
3
|
import { renderType, renderInputType, pascalToDotCase } from './codegen-contract.js';
|
|
4
4
|
import { inferService, deriveModulePath, buildArgs, deriveBaseName } from './codegen-operation.js';
|
|
5
5
|
import { quoteKey, escapeSingleQuoted } from './ts-render.js';
|
|
@@ -155,14 +155,22 @@ function argsSchemaExpr(props: ArgsProp[]): string {
|
|
|
155
155
|
|
|
156
156
|
// ─── Output schema ──────────────────────────────────────────────────────────
|
|
157
157
|
|
|
158
|
-
/**
|
|
159
|
-
|
|
160
|
-
|
|
158
|
+
/**
|
|
159
|
+
* The body an MCP tool reports as its output: the first body the service can actually return.
|
|
160
|
+
*
|
|
161
|
+
* Documented and thrown statuses are skipped — an MCP tool describes what a successful call
|
|
162
|
+
* produces, not what the operation is allowed to document.
|
|
163
|
+
*/
|
|
164
|
+
function primaryResponseBody(op: OpOperationNode): ContractTypeNode | undefined {
|
|
165
|
+
for (const resp of emittedResponses(op)) {
|
|
166
|
+
if (resp.bodies[0]) return resp.bodies[0].bodyType;
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
161
169
|
}
|
|
162
170
|
|
|
163
171
|
/** MCP output schemas must be objects — only model refs and inline objects qualify. */
|
|
164
172
|
function outputSchemaExpr(op: OpOperationNode): string | undefined {
|
|
165
|
-
const body =
|
|
173
|
+
const body = primaryResponseBody(op);
|
|
166
174
|
if (!body) return undefined;
|
|
167
175
|
if (body.kind === 'ref') return body.name;
|
|
168
176
|
if (body.kind === 'inlineObject') return renderType(body);
|
|
@@ -233,7 +241,7 @@ function collectSchemaIds(ops: { route: OpRouteNode; op: OpOperationNode }[], mo
|
|
|
233
241
|
walkSourceRefs(op.query, ids, modelsWithInput);
|
|
234
242
|
walkSourceRefs(op.headers, ids, modelsWithInput);
|
|
235
243
|
|
|
236
|
-
const body =
|
|
244
|
+
const body = primaryResponseBody(op);
|
|
237
245
|
if (body && (body.kind === 'ref' || body.kind === 'inlineObject')) walkTypeRefs(body, ids, 'read');
|
|
238
246
|
}
|
|
239
247
|
return ids;
|
|
@@ -356,7 +364,7 @@ function renderToolClass(plan: ToolPlan, file: string, options: McpCodegenOption
|
|
|
356
364
|
const props = buildArgsProps(route, op, options.modelsWithInput);
|
|
357
365
|
const destructure = props.map(p => p.key);
|
|
358
366
|
const callArgs = buildArgs(route, op);
|
|
359
|
-
const isVoid = !
|
|
367
|
+
const isVoid = !primaryResponseBody(op);
|
|
360
368
|
const structured = !!outExpr;
|
|
361
369
|
|
|
362
370
|
lines.push(' async handle(args: Record<string, unknown>, _context: McpToolContext): Promise<CallToolResult> {');
|
package/src/codegen-operation.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type {
|
|
2
|
+
OpRootNode,
|
|
3
|
+
OpRouteNode,
|
|
4
|
+
OpOperationNode,
|
|
5
|
+
OpResponseNode,
|
|
6
|
+
OpResponseHeaderNode,
|
|
7
|
+
ContractTypeNode,
|
|
8
|
+
ScalarTypeNode,
|
|
9
|
+
ParamSource,
|
|
10
|
+
ObjectMode,
|
|
11
|
+
} from '@contractkit/core';
|
|
12
|
+
import { resolveModifiers, resolveSecurity, SECURITY_NONE, classifyContentType, emittedResponses } from '@contractkit/core';
|
|
3
13
|
import {
|
|
4
14
|
renderType,
|
|
5
15
|
renderInputType,
|
|
6
16
|
renderQueryType,
|
|
7
17
|
pascalToDotCase,
|
|
8
|
-
typeNeedsDateTime,
|
|
9
|
-
typeNeedsScalar,
|
|
10
18
|
modeToWrapper,
|
|
11
19
|
} from './codegen-contract.js';
|
|
12
20
|
import { renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, escapeSingleQuoted } from './ts-render.js';
|
|
@@ -126,89 +134,106 @@ export interface OpCodegenOptions {
|
|
|
126
134
|
includeInternal?: boolean;
|
|
127
135
|
}
|
|
128
136
|
|
|
129
|
-
/**
|
|
137
|
+
/**
|
|
138
|
+
* Generate a Koa router module for every operation in `root`, including the imports, type
|
|
139
|
+
* aliases, and handler list.
|
|
140
|
+
*
|
|
141
|
+
* Imports are derived from the generated body — each candidate symbol is emitted only if it
|
|
142
|
+
* actually appears in the output. Deciding them from predicates over the AST instead means any
|
|
143
|
+
* drift between predicate and codegen leaves an unused import in every generated file, which
|
|
144
|
+
* trips `noUnusedLocals` and lint downstream.
|
|
145
|
+
*/
|
|
130
146
|
export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): string {
|
|
131
147
|
// Collect all referenced types across all routes
|
|
132
148
|
const types = collectTypes(root, options.modelsWithInput, options.modelsWithOutput);
|
|
133
149
|
const services = collectServices(root);
|
|
134
150
|
const routerName = deriveRouterName(root.file);
|
|
135
|
-
const needsParseAndValidate = routeNeedsValidation(root);
|
|
136
|
-
|
|
137
|
-
// Generate the body first so we can detect whether `z.` is actually referenced
|
|
138
|
-
// before deciding whether to emit the zod import.
|
|
139
|
-
const body: string[] = [];
|
|
140
|
-
const needsSignature = fileNeedsSignature(root);
|
|
141
|
-
const needsPolicy = fileNeedsPolicy(root);
|
|
142
|
-
const koaImports = ['ServerKitRouter', 'bodyParserMiddleware'];
|
|
143
|
-
if (needsPolicy) koaImports.push('requirePolicy');
|
|
144
|
-
if (needsSignature) koaImports.push('requireSignature');
|
|
145
|
-
body.push(`import { ${koaImports.join(', ')} } from '@maroonedsoftware/koa';`);
|
|
146
151
|
|
|
147
|
-
|
|
148
|
-
const modulePath = root.services?.[svc] ?? root.meta[svc] ?? deriveModulePath(svc, options.servicePathTemplate);
|
|
149
|
-
body.push(`import { ${svc} } from '${modulePath}';`);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (types.length > 0) {
|
|
153
|
-
body.push(...generateTypeImports(types, root.file, options));
|
|
154
|
-
}
|
|
152
|
+
const lines: string[] = [];
|
|
155
153
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
body.push(`import { ${luxonImports.join(', ')} } from 'luxon';`);
|
|
164
|
-
}
|
|
154
|
+
lines.push('');
|
|
155
|
+
lines.push('/**');
|
|
156
|
+
const relFile = options.outPath ? relative(dirname(options.outPath), root.file) : root.file;
|
|
157
|
+
lines.push(` * generated from [${basename(root.file)}](file://./${relFile})`);
|
|
158
|
+
lines.push('*/');
|
|
159
|
+
lines.push(`export const ${routerName} = ServerKitRouter();`);
|
|
160
|
+
lines.push('');
|
|
165
161
|
|
|
166
|
-
|
|
167
|
-
|
|
162
|
+
const includeInternal = options.includeInternal ?? true;
|
|
163
|
+
for (const route of root.routes) {
|
|
164
|
+
for (const op of route.operations) {
|
|
165
|
+
if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
|
|
166
|
+
lines.push(...generateHandler(route, op, root, options));
|
|
167
|
+
lines.push('');
|
|
168
|
+
}
|
|
168
169
|
}
|
|
169
170
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
171
|
+
// Helpers and imports are both decided from the code we just generated, not from predicates
|
|
172
|
+
// over the AST that have to be kept in step with it by hand. A predicate that drifts leaves an
|
|
173
|
+
// unused declaration in every generated file, which trips `noUnusedLocals` and lint in
|
|
174
|
+
// consuming projects — `opNeedsScalar(root, 'binary')` over-approximates exactly that way,
|
|
175
|
+
// since a binary *response* body is a plain `Buffer` annotation with no schema behind it.
|
|
176
|
+
const handlerBody = lines.join('\n');
|
|
177
|
+
const references = (symbol: string) => new RegExp(`\\b${symbol}\\b`).test(handlerBody);
|
|
173
178
|
|
|
174
179
|
const helpers: string[] = [];
|
|
175
|
-
if (
|
|
180
|
+
if (references('_ZodBinary')) {
|
|
176
181
|
helpers.push(`const _ZodBinary = z.custom<Buffer>((val) => Buffer.isBuffer(val), { error: 'Must be binary data' });`);
|
|
177
182
|
}
|
|
178
|
-
if (
|
|
183
|
+
if (references('_ZodDatetime')) {
|
|
179
184
|
helpers.push(
|
|
180
185
|
`const _ZodDatetime = z.preprocess((val) => typeof val === 'string' ? DateTime.fromISO(val) : val, z.custom<DateTime>((val) => val instanceof DateTime && val.isValid, { message: 'Must be in ISO 8601 format' }));`,
|
|
181
186
|
);
|
|
182
187
|
}
|
|
183
|
-
if (
|
|
188
|
+
if (references('_ZodInterval')) {
|
|
184
189
|
helpers.push(
|
|
185
190
|
`const _ZodInterval = z.preprocess((val) => typeof val === 'string' ? Interval.fromISO(val) : val, z.custom<Interval>((val) => val instanceof Interval && val.isValid, { message: 'Must be an ISO 8601 interval' })).transform(val => val.toISO()!);`,
|
|
186
191
|
);
|
|
187
192
|
}
|
|
188
|
-
|
|
193
|
+
// `_ZodJson`'s own declaration is annotated with `_JsonValue`, so the type alias comes along
|
|
194
|
+
// with it; the alias is also needed on its own for a `json` body's server-side annotation.
|
|
195
|
+
const needsZodJson = references('_ZodJson');
|
|
196
|
+
if (needsZodJson || references('_JsonValue')) {
|
|
189
197
|
helpers.push(`type _JsonValue = string | number | boolean | null | _JsonValue[] | { [key: string]: _JsonValue };`);
|
|
198
|
+
}
|
|
199
|
+
if (needsZodJson) {
|
|
190
200
|
helpers.push(
|
|
191
201
|
`const _ZodJson: z.ZodType<_JsonValue> = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(_ZodJson), z.record(z.string(), _ZodJson)]));`,
|
|
192
202
|
);
|
|
193
203
|
}
|
|
194
204
|
|
|
195
|
-
const
|
|
205
|
+
const generated = [...(helpers.length ? ['', ...helpers] : []), ...lines].join('\n');
|
|
206
|
+
const uses = (symbol: string) => new RegExp(`\\b${symbol}\\b`).test(generated);
|
|
196
207
|
|
|
197
|
-
|
|
198
|
-
lines.push('/**');
|
|
199
|
-
const relFile = options.outPath ? relative(dirname(options.outPath), root.file) : root.file;
|
|
200
|
-
lines.push(` * generated from [${basename(root.file)}](file://./${relFile})`);
|
|
201
|
-
lines.push('*/');
|
|
202
|
-
lines.push(`export const ${routerName} = ServerKitRouter();`);
|
|
203
|
-
lines.push('');
|
|
208
|
+
const body: string[] = [];
|
|
204
209
|
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
const koaImports = ['ServerKitRouter', 'bodyParserMiddleware', 'requirePolicy', 'requireSignature'].filter(uses);
|
|
211
|
+
if (koaImports.length > 0) {
|
|
212
|
+
body.push(`import { ${koaImports.join(', ')} } from '@maroonedsoftware/koa';`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
for (const svc of services) {
|
|
216
|
+
const modulePath = root.services?.[svc] ?? root.meta[svc] ?? deriveModulePath(svc, options.servicePathTemplate);
|
|
217
|
+
body.push(`import { ${svc} } from '${modulePath}';`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (types.length > 0) {
|
|
221
|
+
body.push(...generateTypeImports(types, root.file, options));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// luxon is needed for date/time/datetime (DateTime), duration (Duration) and interval (Interval);
|
|
225
|
+
// the rendered Zod schemas and the service-result annotations both reference these classes.
|
|
226
|
+
const luxonImports = ['DateTime', 'Duration', 'Interval'].filter(uses);
|
|
227
|
+
if (luxonImports.length > 0) {
|
|
228
|
+
body.push(`import { ${luxonImports.join(', ')} } from 'luxon';`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (uses('parseAndValidate')) {
|
|
232
|
+
body.push(`import { parseAndValidate } from '@maroonedsoftware/zod';`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (uses('MultipartBody')) {
|
|
236
|
+
body.push(`import { MultipartBody } from '@maroonedsoftware/multipart';`);
|
|
212
237
|
}
|
|
213
238
|
|
|
214
239
|
const allContent = [...body, ...(helpers.length ? ['', ...helpers] : []), ...lines].join('\n');
|
|
@@ -321,66 +346,177 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
321
346
|
}
|
|
322
347
|
}
|
|
323
348
|
|
|
324
|
-
// Service call
|
|
325
|
-
|
|
349
|
+
// Service call. `emittedResponses` decides which of the declared statuses the service is
|
|
350
|
+
// responsible for producing; the rest are documentation, or the thrown-error path.
|
|
351
|
+
const emitted = emittedResponses(op);
|
|
326
352
|
const serviceParts = inferService(op, route, file);
|
|
327
|
-
const
|
|
353
|
+
const call = `await service.${serviceParts.methodName}(${buildArgs(route, op)})`;
|
|
354
|
+
|
|
355
|
+
if (emitted.length > 1) {
|
|
356
|
+
lines.push(...generateMultiStatusResult(emitted, serviceParts.className, call, options));
|
|
357
|
+
} else {
|
|
358
|
+
lines.push(...generateSingleStatusResult(emitted[0], op, serviceParts.className, call, options));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
lines.push(`});`);
|
|
362
|
+
|
|
363
|
+
return lines;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The service produces exactly one status (or none): the result is the body itself, or
|
|
368
|
+
* `{ body, headers }` when the status declares headers, and `ctx.status` is a constant.
|
|
369
|
+
*
|
|
370
|
+
* A status declaring several mimes also gains a `contentType` the service picks, which is the
|
|
371
|
+
* only thing here that can turn `ctx.type` from a literal into an expression.
|
|
372
|
+
*/
|
|
373
|
+
function generateSingleStatusResult(
|
|
374
|
+
resp: OpResponseNode | undefined,
|
|
375
|
+
op: OpOperationNode,
|
|
376
|
+
className: string,
|
|
377
|
+
call: string,
|
|
378
|
+
options: OpCodegenOptions,
|
|
379
|
+
): string[] {
|
|
380
|
+
const lines: string[] = [];
|
|
381
|
+
const bodies = resp ? resp.bodies : [];
|
|
382
|
+
const respHeaders = resp?.headers ?? [];
|
|
328
383
|
const hasRespHeaders = respHeaders.length > 0;
|
|
329
|
-
const headersAnnotation = hasRespHeaders
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
.join('; ')} }`
|
|
336
|
-
: '';
|
|
337
|
-
|
|
338
|
-
if (primaryResponse?.bodyType) {
|
|
339
|
-
const { annotation, prelude } = formatTypeAnnotation(primaryResponse.bodyType!, options.modelsWithOutput);
|
|
340
|
-
if (prelude) {
|
|
341
|
-
lines.push(` ${prelude}`);
|
|
342
|
-
}
|
|
343
|
-
lines.push(` const service = ctx.container.get(${serviceParts.className});`);
|
|
384
|
+
const headersAnnotation = hasRespHeaders ? renderHeadersAnnotation(respHeaders, options.modelsWithOutput) : '';
|
|
385
|
+
|
|
386
|
+
if (bodies.length === 1) {
|
|
387
|
+
const { annotation, prelude } = formatTypeAnnotation(bodies[0]!.bodyType, options.modelsWithOutput);
|
|
388
|
+
if (prelude) lines.push(` ${prelude}`);
|
|
389
|
+
lines.push(` const service = ctx.container.get(${className});`);
|
|
344
390
|
if (hasRespHeaders) {
|
|
345
|
-
lines.push(
|
|
346
|
-
` const result: { body: ${annotation}; headers: ${headersAnnotation} } = await service.${serviceParts.methodName}(${buildArgs(route, op)});`,
|
|
347
|
-
);
|
|
391
|
+
lines.push(` const result: { body: ${annotation}; headers: ${headersAnnotation} } = ${call};`);
|
|
348
392
|
} else {
|
|
349
|
-
lines.push(` const result: ${annotation} =
|
|
393
|
+
lines.push(` const result: ${annotation} = ${call};`);
|
|
350
394
|
}
|
|
395
|
+
} else if (bodies.length > 1) {
|
|
396
|
+
const { members, preludes } = renderResponseMembers(resp!, options, { includeStatus: false, varPrefix: 'result' });
|
|
397
|
+
for (const prelude of preludes) lines.push(` ${prelude}`);
|
|
398
|
+
lines.push(` const service = ctx.container.get(${className});`);
|
|
399
|
+
lines.push(` const result: ${members.join(' | ')} = ${call};`);
|
|
351
400
|
} else {
|
|
352
|
-
lines.push(` const service = ctx.container.get(${
|
|
401
|
+
lines.push(` const service = ctx.container.get(${className});`);
|
|
353
402
|
if (hasRespHeaders) {
|
|
354
|
-
lines.push(` const result: { headers: ${headersAnnotation} } =
|
|
403
|
+
lines.push(` const result: { headers: ${headersAnnotation} } = ${call};`);
|
|
355
404
|
} else {
|
|
356
|
-
lines.push(`
|
|
405
|
+
lines.push(` ${call};`);
|
|
357
406
|
}
|
|
358
407
|
}
|
|
359
408
|
|
|
360
409
|
lines.push('');
|
|
361
|
-
|
|
410
|
+
// With nothing emitted, the status is still declared somewhere — fall back to the first
|
|
411
|
+
// one written, which is what a documentation-only 3xx/4xx operation means.
|
|
412
|
+
lines.push(` ctx.status = ${resp?.statusCode ?? op.responses[0]?.statusCode ?? 200};`);
|
|
413
|
+
lines.push(...headerSetLines(respHeaders, ' '));
|
|
362
414
|
|
|
363
|
-
if (
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
lines.push(` ctx.set('${h.name}', String(${accessor}));`);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
415
|
+
if (bodies.length === 1) {
|
|
416
|
+
lines.push(` ctx.type = '${bodies[0]!.contentType}';`);
|
|
417
|
+
lines.push(` ctx.body = ${hasRespHeaders ? 'result.body' : 'result'};`);
|
|
418
|
+
} else if (bodies.length > 1) {
|
|
419
|
+
lines.push(` ctx.type = result.contentType;`);
|
|
420
|
+
lines.push(` ctx.body = result.body;`);
|
|
372
421
|
}
|
|
373
422
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
423
|
+
return lines;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* The service chooses between several statuses: the result is a union discriminated on
|
|
428
|
+
* `status`, and the handler switches on it so each status writes only its own headers, mime
|
|
429
|
+
* and body.
|
|
430
|
+
*/
|
|
431
|
+
function generateMultiStatusResult(emitted: OpResponseNode[], className: string, call: string, options: OpCodegenOptions): string[] {
|
|
432
|
+
const lines: string[] = [];
|
|
433
|
+
const members: string[] = [];
|
|
434
|
+
const preludes: string[] = [];
|
|
435
|
+
|
|
436
|
+
for (const resp of emitted) {
|
|
437
|
+
const rendered = renderResponseMembers(resp, options, { includeStatus: true, varPrefix: `result${resp.statusCode}` });
|
|
438
|
+
members.push(...rendered.members);
|
|
439
|
+
preludes.push(...rendered.preludes);
|
|
377
440
|
}
|
|
378
441
|
|
|
379
|
-
lines.push(`})
|
|
442
|
+
for (const prelude of preludes) lines.push(` ${prelude}`);
|
|
443
|
+
lines.push(` const service = ctx.container.get(${className});`);
|
|
444
|
+
lines.push(` const result:`);
|
|
445
|
+
for (const member of members) lines.push(` | ${member}`);
|
|
446
|
+
lines.push(` = ${call};`);
|
|
447
|
+
lines.push('');
|
|
448
|
+
lines.push(` ctx.status = result.status;`);
|
|
449
|
+
lines.push(` switch (result.status) {`);
|
|
450
|
+
for (const resp of emitted) {
|
|
451
|
+
lines.push(` case ${resp.statusCode}:`);
|
|
452
|
+
lines.push(...headerSetLines(resp.headers ?? [], ' '));
|
|
453
|
+
if (resp.bodies.length > 0) {
|
|
454
|
+
lines.push(` ctx.type = result.contentType;`);
|
|
455
|
+
lines.push(` ctx.body = result.body;`);
|
|
456
|
+
}
|
|
457
|
+
lines.push(` break;`);
|
|
458
|
+
}
|
|
459
|
+
lines.push(` }`);
|
|
380
460
|
|
|
381
461
|
return lines;
|
|
382
462
|
}
|
|
383
463
|
|
|
464
|
+
/**
|
|
465
|
+
* Render one status as the members of the service-result union — its `contentType`, `body` and
|
|
466
|
+
* `headers`, plus `status` when the operation emits more than one.
|
|
467
|
+
*
|
|
468
|
+
* A status declaring several mimes collapses to a single member with a union of mime literals
|
|
469
|
+
* when the bodies are structurally identical (`image/png` and `image/jpeg` both `binary`).
|
|
470
|
+
* When they differ, it produces one member per mime so `contentType` and `body` stay correlated.
|
|
471
|
+
*/
|
|
472
|
+
function renderResponseMembers(
|
|
473
|
+
resp: OpResponseNode,
|
|
474
|
+
options: OpCodegenOptions,
|
|
475
|
+
opts: { includeStatus: boolean; varPrefix: string },
|
|
476
|
+
): { members: string[]; preludes: string[] } {
|
|
477
|
+
const bodies = resp.bodies;
|
|
478
|
+
const headers = resp.headers ?? [];
|
|
479
|
+
const leading = opts.includeStatus ? [`status: ${resp.statusCode}`] : [];
|
|
480
|
+
const trailing = headers.length > 0 ? [`headers: ${renderHeadersAnnotation(headers, options.modelsWithOutput)}`] : [];
|
|
481
|
+
const preludes: string[] = [];
|
|
482
|
+
|
|
483
|
+
if (bodies.length === 0) {
|
|
484
|
+
return { members: [`{ ${[...leading, ...trailing].join('; ')} }`], preludes };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const uniform = bodies.every(b => bodyTypesStructurallyEqual(b.bodyType, bodies[0]!.bodyType));
|
|
488
|
+
if (uniform) {
|
|
489
|
+
const { annotation, prelude } = formatTypeAnnotation(bodies[0]!.bodyType, options.modelsWithOutput, `${opts.varPrefix}Type`);
|
|
490
|
+
if (prelude) preludes.push(prelude);
|
|
491
|
+
const contentType = bodies.map(b => `'${b.contentType}'`).join(' | ');
|
|
492
|
+
return { members: [`{ ${[...leading, `contentType: ${contentType}`, `body: ${annotation}`, ...trailing].join('; ')} }`], preludes };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const members = bodies.map((b, i) => {
|
|
496
|
+
const { annotation, prelude } = formatTypeAnnotation(b.bodyType, options.modelsWithOutput, `${opts.varPrefix}Type${i}`);
|
|
497
|
+
if (prelude) preludes.push(prelude);
|
|
498
|
+
return `{ ${[...leading, `contentType: '${b.contentType}'`, `body: ${annotation}`, ...trailing].join('; ')} }`;
|
|
499
|
+
});
|
|
500
|
+
return { members, preludes };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function renderHeadersAnnotation(headers: OpResponseHeaderNode[], modelsWithOutput?: Set<string>): string {
|
|
504
|
+
const fields = headers.map(
|
|
505
|
+
h => `${quoteKey(headerNameToProperty(h.name))}${h.optional ? '?' : ''}: ${renderOutputTsType(h.type, modelsWithOutput, 'server')}`,
|
|
506
|
+
);
|
|
507
|
+
return `{ ${fields.join('; ')} }`;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** `ctx.set(...)` calls for a status's declared response headers, guarding the optional ones. */
|
|
511
|
+
function headerSetLines(headers: OpResponseHeaderNode[], indent: string): string[] {
|
|
512
|
+
return headers.map(h => {
|
|
513
|
+
const accessor = `result.headers[${JSON.stringify(headerNameToProperty(h.name))}]`;
|
|
514
|
+
return h.optional
|
|
515
|
+
? `${indent}if (${accessor} !== undefined) ctx.set('${h.name}', String(${accessor}));`
|
|
516
|
+
: `${indent}ctx.set('${h.name}', String(${accessor}));`;
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
384
520
|
// ─── Inference helpers ─────────────────────────────────────────────────────
|
|
385
521
|
|
|
386
522
|
/**
|
|
@@ -502,9 +638,13 @@ function serverTsScalar(name: ScalarTypeNode['name']): string {
|
|
|
502
638
|
}
|
|
503
639
|
}
|
|
504
640
|
|
|
505
|
-
|
|
641
|
+
/**
|
|
642
|
+
* @param varName Name for the extracted schema variable. Distinct per status and per mime when
|
|
643
|
+
* an operation emits several, so two complex bodies in one handler cannot collide.
|
|
644
|
+
*/
|
|
645
|
+
function formatTypeAnnotation(bodyType: ContractTypeNode, modelsWithOutput?: Set<string>, varName = 'resultType'): { annotation: string; prelude?: string } {
|
|
506
646
|
if (bodyType.kind === 'array') {
|
|
507
|
-
const inner = formatTypeAnnotation(bodyType.item, modelsWithOutput);
|
|
647
|
+
const inner = formatTypeAnnotation(bodyType.item, modelsWithOutput, varName);
|
|
508
648
|
return { annotation: `${inner.annotation}[]`, prelude: inner.prelude };
|
|
509
649
|
}
|
|
510
650
|
if (bodyType.kind === 'ref') {
|
|
@@ -515,8 +655,8 @@ function formatTypeAnnotation(bodyType: ContractTypeNode, modelsWithOutput?: Set
|
|
|
515
655
|
// For complex types, extract schema into a variable so the result line stays readable
|
|
516
656
|
const schema = renderType(bodyType);
|
|
517
657
|
return {
|
|
518
|
-
annotation:
|
|
519
|
-
prelude: `const
|
|
658
|
+
annotation: `z.infer<typeof ${varName}>`,
|
|
659
|
+
prelude: `const ${varName} = ${schema};`,
|
|
520
660
|
};
|
|
521
661
|
}
|
|
522
662
|
|
|
@@ -634,9 +774,9 @@ function collectTypes(root: OpRootNode, modelsWithInput?: Set<string>, modelsWit
|
|
|
634
774
|
}
|
|
635
775
|
}
|
|
636
776
|
for (const resp of op.responses) {
|
|
637
|
-
|
|
638
|
-
collectTypeNodeRefs(
|
|
639
|
-
collectOutputTypeNodeRefs(
|
|
777
|
+
for (const body of resp.bodies) {
|
|
778
|
+
collectTypeNodeRefs(body.bodyType, types);
|
|
779
|
+
collectOutputTypeNodeRefs(body.bodyType, types, modelsWithOutput);
|
|
640
780
|
}
|
|
641
781
|
if (resp.headers) {
|
|
642
782
|
for (const h of resp.headers) {
|
|
@@ -780,48 +920,6 @@ function collectTypeNodeRefs(type: ContractTypeNode, out: Set<string>): void {
|
|
|
780
920
|
}
|
|
781
921
|
}
|
|
782
922
|
|
|
783
|
-
function paramSourceNeedsDateTime(source: ParamSource | undefined): boolean {
|
|
784
|
-
if (!source) return false;
|
|
785
|
-
if (source.kind === 'ref') return false;
|
|
786
|
-
if (source.kind === 'params') return source.nodes.some(p => typeNeedsDateTime(p.type));
|
|
787
|
-
return typeNeedsDateTime(source.node);
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
function opNeedsDateTime(root: OpRootNode): boolean {
|
|
791
|
-
return root.routes.some(
|
|
792
|
-
route =>
|
|
793
|
-
paramSourceNeedsDateTime(route.params) ||
|
|
794
|
-
route.operations.some(
|
|
795
|
-
op =>
|
|
796
|
-
!!op.request?.bodies.some(b => typeNeedsDateTime(b.bodyType)) ||
|
|
797
|
-
op.responses.some(r => r.bodyType && typeNeedsDateTime(r.bodyType)) ||
|
|
798
|
-
paramSourceNeedsDateTime(op.query) ||
|
|
799
|
-
paramSourceNeedsDateTime(op.headers),
|
|
800
|
-
),
|
|
801
|
-
);
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
function paramSourceNeedsScalar(source: ParamSource | undefined, name: string): boolean {
|
|
805
|
-
if (!source) return false;
|
|
806
|
-
if (source.kind === 'ref') return false;
|
|
807
|
-
if (source.kind === 'params') return source.nodes.some(p => typeNeedsScalar(p.type, name));
|
|
808
|
-
return typeNeedsScalar(source.node, name);
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
function opNeedsScalar(root: OpRootNode, name: string): boolean {
|
|
812
|
-
return root.routes.some(
|
|
813
|
-
route =>
|
|
814
|
-
paramSourceNeedsScalar(route.params, name) ||
|
|
815
|
-
route.operations.some(
|
|
816
|
-
op =>
|
|
817
|
-
!!op.request?.bodies.some(b => typeNeedsScalar(b.bodyType, name)) ||
|
|
818
|
-
op.responses.some(r => r.bodyType && typeNeedsScalar(r.bodyType, name)) ||
|
|
819
|
-
paramSourceNeedsScalar(op.query, name) ||
|
|
820
|
-
paramSourceNeedsScalar(op.headers, name),
|
|
821
|
-
),
|
|
822
|
-
);
|
|
823
|
-
}
|
|
824
|
-
|
|
825
923
|
function collectServices(root: OpRootNode): string[] {
|
|
826
924
|
const services = new Set<string>();
|
|
827
925
|
const inferredService = `${deriveBaseName(root.file)}Service`;
|
|
@@ -838,32 +936,10 @@ function collectServices(root: OpRootNode): string[] {
|
|
|
838
936
|
return [...services].sort();
|
|
839
937
|
}
|
|
840
938
|
|
|
841
|
-
function hasParamSource(source?: ParamSource): boolean {
|
|
842
|
-
if (!source) return false;
|
|
843
|
-
if (source.kind === 'ref') return true;
|
|
844
|
-
if (source.kind === 'params') return source.nodes.length > 0;
|
|
845
|
-
return true; // type
|
|
846
|
-
}
|
|
847
939
|
|
|
848
|
-
function routeNeedsValidation(root: OpRootNode): boolean {
|
|
849
|
-
return root.routes.some(
|
|
850
|
-
r => hasParamSource(r.params) || r.operations.some(op => !!op.request || hasParamSource(op.query) || hasParamSource(op.headers)),
|
|
851
|
-
);
|
|
852
|
-
}
|
|
853
940
|
|
|
854
|
-
function fileNeedsPolicy(root: OpRootNode): boolean {
|
|
855
|
-
return root.routes.some(route => route.operations.some(op => resolveSecurity(route, op, root) !== SECURITY_NONE));
|
|
856
|
-
}
|
|
857
941
|
|
|
858
|
-
function fileNeedsSignature(root: OpRootNode): boolean {
|
|
859
|
-
return root.routes.some(route => route.operations.some(op => !!op.signature));
|
|
860
|
-
}
|
|
861
942
|
|
|
862
|
-
function fileUsesMultipart(root: OpRootNode): boolean {
|
|
863
|
-
return root.routes.some(route =>
|
|
864
|
-
route.operations.some(op => (op.request?.bodies ?? []).some(b => b.contentType === 'multipart/form-data')),
|
|
865
|
-
);
|
|
866
|
-
}
|
|
867
943
|
|
|
868
944
|
function isValidIdentifier(name: string): boolean {
|
|
869
945
|
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name);
|