@contractkit/plugin-typescript 0.28.2 → 0.30.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 +19 -19
- package/CHANGELOG.md +23 -0
- package/README.md +4 -0
- package/dist/codegen-contract.d.ts +7 -0
- package/dist/codegen-contract.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-plain-types.d.ts +3 -0
- package/dist/codegen-plain-types.d.ts.map +1 -1
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +144 -120
- package/dist/index.js.map +1 -1
- package/dist/ts-render.d.ts +21 -3
- package/dist/ts-render.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/codegen-contract.ts +7 -0
- package/src/codegen-operation.ts +103 -78
- package/src/codegen-plain-types.ts +47 -22
- package/src/index.ts +21 -1
- package/src/ts-render.ts +52 -32
- package/tests/codegen-operation.test.ts +134 -3
- package/tests/codegen-plain-types.test.ts +48 -0
- package/tests/codegen-server.test.ts +37 -0
package/dist/ts-render.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ContractTypeNode } from '@contractkit/core';
|
|
2
|
+
/** Declaration emitted into generated files that reference the `json` scalar. */
|
|
2
3
|
export declare const JSON_VALUE_TYPE_DECL = "export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };";
|
|
4
|
+
/** Quote a property name unless it is already a valid bare TypeScript identifier. */
|
|
3
5
|
export declare function quoteKey(name: string): string;
|
|
4
6
|
/** Escape text for safe inclusion inside a JSDoc block comment: neutralize the
|
|
5
7
|
* block-comment terminator sequence and split embedded newlines into separate
|
|
@@ -9,18 +11,34 @@ export declare function escapeJsDocLines(text: string): string[];
|
|
|
9
11
|
export declare function escapeSingleQuoted(s: string): string;
|
|
10
12
|
/** Convert an HTTP header name (e.g. `preference-applied`, `X-Request-ID`, `ETag`) to camelCase for use as a JS property. */
|
|
11
13
|
export declare function headerNameToProperty(name: string): string;
|
|
12
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Which runtime the emitted types describe. A few scalars have no single correct TypeScript
|
|
16
|
+
* type: `binary` is a `Blob` in a fetch-based client but a `Buffer` on a Node server. Everything
|
|
17
|
+
* else renders identically for both targets. Defaults to `'client'`.
|
|
18
|
+
*/
|
|
19
|
+
export type TsRenderTarget = 'client' | 'server';
|
|
20
|
+
/**
|
|
21
|
+
* Render a contract type as a plain TypeScript type expression. Model refs render as their bare
|
|
22
|
+
* name; use `renderInputTsType` / `renderOutputTsType` to substitute Input/Output variants.
|
|
23
|
+
*
|
|
24
|
+
* @param target Runtime the type describes; only `binary` differs (`Buffer` vs `Blob`).
|
|
25
|
+
*/
|
|
26
|
+
export declare function renderTsType(type: ContractTypeNode, target?: TsRenderTarget): string;
|
|
13
27
|
/**
|
|
14
28
|
* Like renderTsType, but substitutes model refs with their Input variant
|
|
15
29
|
* when the model has visibility modifiers. Used for request-side types
|
|
16
30
|
* (body, params, query, headers).
|
|
31
|
+
*
|
|
32
|
+
* @param target Runtime the type describes; only `binary` differs (`Buffer` vs `Blob`).
|
|
17
33
|
*/
|
|
18
|
-
export declare function renderInputTsType(type: ContractTypeNode, modelsWithInput?: Set<string
|
|
34
|
+
export declare function renderInputTsType(type: ContractTypeNode, modelsWithInput?: Set<string>, target?: TsRenderTarget): string;
|
|
19
35
|
/**
|
|
20
36
|
* Like renderTsType, but substitutes model refs with their Output variant
|
|
21
37
|
* (post-transform wire shape) when the model has format(output=...) or
|
|
22
38
|
* transitively references one. Used for response-side types in routers
|
|
23
39
|
* and SDK return types.
|
|
40
|
+
*
|
|
41
|
+
* @param target Runtime the type describes; only `binary` differs (`Buffer` vs `Blob`).
|
|
24
42
|
*/
|
|
25
|
-
export declare function renderOutputTsType(type: ContractTypeNode, modelsWithOutput?: Set<string
|
|
43
|
+
export declare function renderOutputTsType(type: ContractTypeNode, modelsWithOutput?: Set<string>, target?: TsRenderTarget): string;
|
|
26
44
|
//# sourceMappingURL=ts-render.d.ts.map
|
package/dist/ts-render.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAA6B,MAAM,mBAAmB,CAAC;AAErF,eAAO,MAAM,oBAAoB,2GAA2G,CAAC;AAE7I,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;sFAEsF;AACtF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAEvD;AAED,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,6HAA6H;AAC7H,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQzD;AAID,wBAAgB,YAAY,CAAC,IAAI,EAAE,gBAAgB,GAAG,MAAM,
|
|
1
|
+
{"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAA6B,MAAM,mBAAmB,CAAC;AAErF,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,2GAA2G,CAAC;AAE7I,qFAAqF;AACrF,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;sFAEsF;AACtF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAEvD;AAED,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,6HAA6H;AAC7H,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQzD;AAID;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEjD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,gBAAgB,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CAoC9F;AAgDD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,EAAE,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CA2BlI;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,EAAE,gBAAgB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CA2BpI"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contractkit/plugin-typescript",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.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.24.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@repo/config-eslint": "0.3.1",
|
package/src/codegen-contract.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
collectExternalOutputRefs as ckCollectExternalOutputRefs,
|
|
23
23
|
} from '@contractkit/core';
|
|
24
24
|
import { escapeJsDocLines } from './ts-render.js';
|
|
25
|
+
import type { TsRenderTarget } from './ts-render.js';
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Maps a ContractKit object mode to its Zod constructor name.
|
|
@@ -53,6 +54,12 @@ export interface ContractCodegenContext {
|
|
|
53
54
|
modelsWithOutput?: Set<string>;
|
|
54
55
|
/** If set, import JsonValue from this path instead of re-declaring it (avoids barrel re-export conflicts) */
|
|
55
56
|
jsonValueImportPath?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Runtime the emitted plain types describe. Only affects scalars whose TypeScript type differs
|
|
59
|
+
* per runtime (`binary` → `Buffer` on the server, `Blob` in the client). Ignored by
|
|
60
|
+
* `generateContract`, whose Zod schemas are server-shaped by construction. Default `'client'`.
|
|
61
|
+
*/
|
|
62
|
+
target?: TsRenderTarget;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
// ─── Public entry point ────────────────────────────────────────────────────
|
package/src/codegen-operation.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import type { OpRootNode, OpRouteNode, OpOperationNode, ContractTypeNode, ParamSource, ObjectMode } from '@contractkit/core';
|
|
1
|
+
import type { OpRootNode, OpRouteNode, OpOperationNode, ContractTypeNode, ScalarTypeNode, ParamSource, ObjectMode } from '@contractkit/core';
|
|
2
2
|
import { resolveModifiers, resolveSecurity, SECURITY_NONE, classifyContentType } from '@contractkit/core';
|
|
3
3
|
import {
|
|
4
4
|
renderType,
|
|
5
5
|
renderInputType,
|
|
6
6
|
renderQueryType,
|
|
7
7
|
pascalToDotCase,
|
|
8
|
-
typeNeedsDateTime,
|
|
9
8
|
typeNeedsScalar,
|
|
10
9
|
modeToWrapper,
|
|
11
10
|
} from './codegen-contract.js';
|
|
@@ -126,44 +125,20 @@ export interface OpCodegenOptions {
|
|
|
126
125
|
includeInternal?: boolean;
|
|
127
126
|
}
|
|
128
127
|
|
|
129
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* Generate a Koa router module for every operation in `root`, including the imports, type
|
|
130
|
+
* aliases, and handler list.
|
|
131
|
+
*
|
|
132
|
+
* Imports are derived from the generated body — each candidate symbol is emitted only if it
|
|
133
|
+
* actually appears in the output. Deciding them from predicates over the AST instead means any
|
|
134
|
+
* drift between predicate and codegen leaves an unused import in every generated file, which
|
|
135
|
+
* trips `noUnusedLocals` and lint downstream.
|
|
136
|
+
*/
|
|
130
137
|
export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): string {
|
|
131
138
|
// Collect all referenced types across all routes
|
|
132
139
|
const types = collectTypes(root, options.modelsWithInput, options.modelsWithOutput);
|
|
133
140
|
const services = collectServices(root);
|
|
134
141
|
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
|
-
|
|
147
|
-
for (const svc of services) {
|
|
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
|
-
}
|
|
155
|
-
|
|
156
|
-
if (opNeedsDateTime(root)) {
|
|
157
|
-
body.push(`import { DateTime } from 'luxon';`);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (needsParseAndValidate) {
|
|
161
|
-
body.push(`import { parseAndValidate } from '@maroonedsoftware/zod';`);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (fileUsesMultipart(root)) {
|
|
165
|
-
body.push(`import { MultipartBody } from '@maroonedsoftware/multipart';`);
|
|
166
|
-
}
|
|
167
142
|
|
|
168
143
|
const helpers: string[] = [];
|
|
169
144
|
if (opNeedsScalar(root, 'binary')) {
|
|
@@ -174,6 +149,11 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
|
|
|
174
149
|
`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' }));`,
|
|
175
150
|
);
|
|
176
151
|
}
|
|
152
|
+
if (opNeedsScalar(root, 'interval')) {
|
|
153
|
+
helpers.push(
|
|
154
|
+
`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()!);`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
177
157
|
if (opNeedsScalar(root, 'json')) {
|
|
178
158
|
helpers.push(`type _JsonValue = string | number | boolean | null | _JsonValue[] | { [key: string]: _JsonValue };`);
|
|
179
159
|
helpers.push(
|
|
@@ -200,6 +180,43 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
|
|
|
200
180
|
}
|
|
201
181
|
}
|
|
202
182
|
|
|
183
|
+
// Imports are decided from the code we just generated, not from predicates over the AST that
|
|
184
|
+
// have to be kept in step with it by hand. A predicate that drifts leaves an unused import in
|
|
185
|
+
// every generated file, which trips `noUnusedLocals` and lint in consuming projects.
|
|
186
|
+
const generated = [...(helpers.length ? ['', ...helpers] : []), ...lines].join('\n');
|
|
187
|
+
const uses = (symbol: string) => new RegExp(`\\b${symbol}\\b`).test(generated);
|
|
188
|
+
|
|
189
|
+
const body: string[] = [];
|
|
190
|
+
|
|
191
|
+
const koaImports = ['ServerKitRouter', 'bodyParserMiddleware', 'requirePolicy', 'requireSignature'].filter(uses);
|
|
192
|
+
if (koaImports.length > 0) {
|
|
193
|
+
body.push(`import { ${koaImports.join(', ')} } from '@maroonedsoftware/koa';`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const svc of services) {
|
|
197
|
+
const modulePath = root.services?.[svc] ?? root.meta[svc] ?? deriveModulePath(svc, options.servicePathTemplate);
|
|
198
|
+
body.push(`import { ${svc} } from '${modulePath}';`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (types.length > 0) {
|
|
202
|
+
body.push(...generateTypeImports(types, root.file, options));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// luxon is needed for date/time/datetime (DateTime), duration (Duration) and interval (Interval);
|
|
206
|
+
// the rendered Zod schemas and the service-result annotations both reference these classes.
|
|
207
|
+
const luxonImports = ['DateTime', 'Duration', 'Interval'].filter(uses);
|
|
208
|
+
if (luxonImports.length > 0) {
|
|
209
|
+
body.push(`import { ${luxonImports.join(', ')} } from 'luxon';`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (uses('parseAndValidate')) {
|
|
213
|
+
body.push(`import { parseAndValidate } from '@maroonedsoftware/zod';`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (uses('MultipartBody')) {
|
|
217
|
+
body.push(`import { MultipartBody } from '@maroonedsoftware/multipart';`);
|
|
218
|
+
}
|
|
219
|
+
|
|
203
220
|
const allContent = [...body, ...(helpers.length ? ['', ...helpers] : []), ...lines].join('\n');
|
|
204
221
|
const needsZod = /\bz\./.test(allContent);
|
|
205
222
|
return (needsZod ? `import { z } from 'zod';\n` : '') + allContent;
|
|
@@ -317,7 +334,10 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
317
334
|
const hasRespHeaders = respHeaders.length > 0;
|
|
318
335
|
const headersAnnotation = hasRespHeaders
|
|
319
336
|
? `{ ${respHeaders
|
|
320
|
-
.map(
|
|
337
|
+
.map(
|
|
338
|
+
h =>
|
|
339
|
+
`${quoteKey(headerNameToProperty(h.name))}${h.optional ? '?' : ''}: ${renderOutputTsType(h.type, options.modelsWithOutput, 'server')}`,
|
|
340
|
+
)
|
|
321
341
|
.join('; ')} }`
|
|
322
342
|
: '';
|
|
323
343
|
|
|
@@ -442,6 +462,52 @@ export function buildArgs(route: OpRouteNode, op: OpOperationNode): string {
|
|
|
442
462
|
return args.join(', ');
|
|
443
463
|
}
|
|
444
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Map a `.ck` scalar to the TypeScript type a server handler sees, i.e. `z.infer` of the schema
|
|
467
|
+
* `renderType` emits for that scalar. This is deliberately NOT `renderTsScalar` from ts-render:
|
|
468
|
+
* that one describes the wire/SDK view (`binary` → `Blob`, dates → `string`), while the router
|
|
469
|
+
* runs on Node against the parsed Zod output (`binary` → `Buffer`, dates → luxon `DateTime`).
|
|
470
|
+
*/
|
|
471
|
+
function serverTsScalar(name: ScalarTypeNode['name']): string {
|
|
472
|
+
switch (name) {
|
|
473
|
+
case 'string':
|
|
474
|
+
case 'email':
|
|
475
|
+
case 'url':
|
|
476
|
+
case 'uuid':
|
|
477
|
+
return 'string';
|
|
478
|
+
case 'number':
|
|
479
|
+
case 'int':
|
|
480
|
+
return 'number';
|
|
481
|
+
case 'bigint':
|
|
482
|
+
return 'bigint';
|
|
483
|
+
case 'boolean':
|
|
484
|
+
return 'boolean';
|
|
485
|
+
case 'date':
|
|
486
|
+
case 'time':
|
|
487
|
+
case 'datetime':
|
|
488
|
+
return 'DateTime';
|
|
489
|
+
case 'duration':
|
|
490
|
+
return 'Duration';
|
|
491
|
+
case 'interval':
|
|
492
|
+
// _ZodInterval transforms to an ISO string, so the inferred output type is string.
|
|
493
|
+
return 'string';
|
|
494
|
+
case 'binary':
|
|
495
|
+
return 'Buffer';
|
|
496
|
+
case 'json':
|
|
497
|
+
return '_JsonValue';
|
|
498
|
+
case 'object':
|
|
499
|
+
return 'Record<string, unknown>';
|
|
500
|
+
case 'null':
|
|
501
|
+
return 'null';
|
|
502
|
+
case 'unknown':
|
|
503
|
+
return 'unknown';
|
|
504
|
+
default: {
|
|
505
|
+
const _exhaustive: never = name;
|
|
506
|
+
throw new Error(`plugin-typescript: unmapped scalar '${String(_exhaustive)}' — add a case`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
445
511
|
function formatTypeAnnotation(bodyType: ContractTypeNode, modelsWithOutput?: Set<string>): { annotation: string; prelude?: string } {
|
|
446
512
|
if (bodyType.kind === 'array') {
|
|
447
513
|
const inner = formatTypeAnnotation(bodyType.item, modelsWithOutput);
|
|
@@ -451,7 +517,7 @@ function formatTypeAnnotation(bodyType: ContractTypeNode, modelsWithOutput?: Set
|
|
|
451
517
|
const name = modelsWithOutput?.has(bodyType.name) ? `${bodyType.name}Output` : bodyType.name;
|
|
452
518
|
return { annotation: name };
|
|
453
519
|
}
|
|
454
|
-
if (bodyType.kind === 'scalar') return { annotation: bodyType.name };
|
|
520
|
+
if (bodyType.kind === 'scalar') return { annotation: serverTsScalar(bodyType.name) };
|
|
455
521
|
// For complex types, extract schema into a variable so the result line stays readable
|
|
456
522
|
const schema = renderType(bodyType);
|
|
457
523
|
return {
|
|
@@ -720,26 +786,7 @@ function collectTypeNodeRefs(type: ContractTypeNode, out: Set<string>): void {
|
|
|
720
786
|
}
|
|
721
787
|
}
|
|
722
788
|
|
|
723
|
-
function paramSourceNeedsDateTime(source: ParamSource | undefined): boolean {
|
|
724
|
-
if (!source) return false;
|
|
725
|
-
if (source.kind === 'ref') return false;
|
|
726
|
-
if (source.kind === 'params') return source.nodes.some(p => typeNeedsDateTime(p.type));
|
|
727
|
-
return typeNeedsDateTime(source.node);
|
|
728
|
-
}
|
|
729
789
|
|
|
730
|
-
function opNeedsDateTime(root: OpRootNode): boolean {
|
|
731
|
-
return root.routes.some(
|
|
732
|
-
route =>
|
|
733
|
-
paramSourceNeedsDateTime(route.params) ||
|
|
734
|
-
route.operations.some(
|
|
735
|
-
op =>
|
|
736
|
-
!!op.request?.bodies.some(b => typeNeedsDateTime(b.bodyType)) ||
|
|
737
|
-
op.responses.some(r => r.bodyType && typeNeedsDateTime(r.bodyType)) ||
|
|
738
|
-
paramSourceNeedsDateTime(op.query) ||
|
|
739
|
-
paramSourceNeedsDateTime(op.headers),
|
|
740
|
-
),
|
|
741
|
-
);
|
|
742
|
-
}
|
|
743
790
|
|
|
744
791
|
function paramSourceNeedsScalar(source: ParamSource | undefined, name: string): boolean {
|
|
745
792
|
if (!source) return false;
|
|
@@ -778,32 +825,10 @@ function collectServices(root: OpRootNode): string[] {
|
|
|
778
825
|
return [...services].sort();
|
|
779
826
|
}
|
|
780
827
|
|
|
781
|
-
function hasParamSource(source?: ParamSource): boolean {
|
|
782
|
-
if (!source) return false;
|
|
783
|
-
if (source.kind === 'ref') return true;
|
|
784
|
-
if (source.kind === 'params') return source.nodes.length > 0;
|
|
785
|
-
return true; // type
|
|
786
|
-
}
|
|
787
828
|
|
|
788
|
-
function routeNeedsValidation(root: OpRootNode): boolean {
|
|
789
|
-
return root.routes.some(
|
|
790
|
-
r => hasParamSource(r.params) || r.operations.some(op => !!op.request || hasParamSource(op.query) || hasParamSource(op.headers)),
|
|
791
|
-
);
|
|
792
|
-
}
|
|
793
829
|
|
|
794
|
-
function fileNeedsPolicy(root: OpRootNode): boolean {
|
|
795
|
-
return root.routes.some(route => route.operations.some(op => resolveSecurity(route, op, root) !== SECURITY_NONE));
|
|
796
|
-
}
|
|
797
830
|
|
|
798
|
-
function fileNeedsSignature(root: OpRootNode): boolean {
|
|
799
|
-
return root.routes.some(route => route.operations.some(op => !!op.signature));
|
|
800
|
-
}
|
|
801
831
|
|
|
802
|
-
function fileUsesMultipart(root: OpRootNode): boolean {
|
|
803
|
-
return root.routes.some(route =>
|
|
804
|
-
route.operations.some(op => (op.request?.bodies ?? []).some(b => b.contentType === 'multipart/form-data')),
|
|
805
|
-
);
|
|
806
|
-
}
|
|
807
832
|
|
|
808
833
|
function isValidIdentifier(name: string): boolean {
|
|
809
834
|
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name);
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
rootNeedsScalar,
|
|
12
12
|
} from './codegen-contract.js';
|
|
13
13
|
import { renderTsType, renderInputTsType, renderOutputTsType, quoteKey, escapeJsDocLines, JSON_VALUE_TYPE_DECL } from './ts-render.js';
|
|
14
|
+
import type { TsRenderTarget } from './ts-render.js';
|
|
14
15
|
|
|
15
16
|
// ─── Public entry point ────────────────────────────────────────────────────
|
|
16
17
|
|
|
@@ -19,8 +20,12 @@ import { renderTsType, renderInputTsType, renderOutputTsType, quoteKey, escapeJs
|
|
|
19
20
|
* Unlike `generateContract()` which produces Zod schemas, this emits
|
|
20
21
|
* vanilla TypeScript `interface` and `type` declarations suitable
|
|
21
22
|
* for SDK consumers that don't need runtime validation.
|
|
23
|
+
*
|
|
24
|
+
* @param context Import resolution and Input/Output variant sets. `context.target` selects the
|
|
25
|
+
* runtime the types describe (`'server'` renders `binary` as `Buffer`, `'client'` as `Blob`).
|
|
22
26
|
*/
|
|
23
27
|
export function generatePlainTypes(root: ContractRootNode, context?: ContractCodegenContext): string {
|
|
28
|
+
const target: TsRenderTarget = context?.target ?? 'client';
|
|
24
29
|
const externalRefs = collectExternalRefs(root);
|
|
25
30
|
const lines: string[] = [];
|
|
26
31
|
|
|
@@ -58,7 +63,7 @@ export function generatePlainTypes(root: ContractRootNode, context?: ContractCod
|
|
|
58
63
|
const modelMap = new Map(root.models.map(m => [m.name, m]));
|
|
59
64
|
|
|
60
65
|
for (const model of topoSortModels(root.models)) {
|
|
61
|
-
lines.push(...generateModel(model, context?.currentOutPath, allModelsWithInput, allModelsWithOutput, modelMap));
|
|
66
|
+
lines.push(...generateModel(model, target, context?.currentOutPath, allModelsWithInput, allModelsWithOutput, modelMap));
|
|
62
67
|
lines.push('');
|
|
63
68
|
}
|
|
64
69
|
|
|
@@ -69,6 +74,7 @@ export function generatePlainTypes(root: ContractRootNode, context?: ContractCod
|
|
|
69
74
|
|
|
70
75
|
function generateModel(
|
|
71
76
|
model: ModelNode,
|
|
77
|
+
target: TsRenderTarget,
|
|
72
78
|
outPath?: string,
|
|
73
79
|
modelsWithInput?: Set<string>,
|
|
74
80
|
modelsWithOutput?: Set<string>,
|
|
@@ -76,18 +82,20 @@ function generateModel(
|
|
|
76
82
|
): string[] {
|
|
77
83
|
// Type alias: Name : typeExpression
|
|
78
84
|
if (model.type) {
|
|
79
|
-
return generateTypeAlias(model, outPath, modelsWithInput, modelsWithOutput);
|
|
85
|
+
return generateTypeAlias(model, target, outPath, modelsWithInput, modelsWithOutput);
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
// A model needs Input/read split if it has visibility-modified fields OR if it
|
|
83
89
|
// transitively references models that have Input variants (captured in modelsWithInput).
|
|
84
90
|
const needsInputSplit = model.fields.some(f => f.visibility !== 'normal') || (modelsWithInput?.has(model.name) ?? false);
|
|
85
91
|
|
|
86
|
-
const lines = needsInputSplit
|
|
92
|
+
const lines = needsInputSplit
|
|
93
|
+
? generateVisibilityModel(model, target, outPath, modelsWithInput, modelMap)
|
|
94
|
+
: generateSimpleModel(model, target, outPath, modelMap);
|
|
87
95
|
|
|
88
96
|
if (modelsWithOutput?.has(model.name)) {
|
|
89
97
|
lines.push('');
|
|
90
|
-
lines.push(...generateOutputModel(model, modelsWithOutput));
|
|
98
|
+
lines.push(...generateOutputModel(model, target, modelsWithOutput));
|
|
91
99
|
}
|
|
92
100
|
return lines;
|
|
93
101
|
}
|
|
@@ -132,15 +140,21 @@ function generateComments(model: ModelNode, outPath?: string): string[] {
|
|
|
132
140
|
return lines;
|
|
133
141
|
}
|
|
134
142
|
|
|
135
|
-
function generateTypeAlias(
|
|
143
|
+
function generateTypeAlias(
|
|
144
|
+
model: ModelNode,
|
|
145
|
+
target: TsRenderTarget,
|
|
146
|
+
outPath?: string,
|
|
147
|
+
modelsWithInput?: Set<string>,
|
|
148
|
+
modelsWithOutput?: Set<string>,
|
|
149
|
+
): string[] {
|
|
136
150
|
const lines: string[] = [];
|
|
137
151
|
lines.push(...generateComments(model, outPath));
|
|
138
|
-
lines.push(`export type ${model.name} = ${renderTsType(model.type
|
|
152
|
+
lines.push(`export type ${model.name} = ${renderTsType(model.type!, target)};`);
|
|
139
153
|
if (modelsWithInput?.has(model.name)) {
|
|
140
|
-
lines.push(`export type ${model.name}Input = ${renderInputTsType(model.type!, modelsWithInput)};`);
|
|
154
|
+
lines.push(`export type ${model.name}Input = ${renderInputTsType(model.type!, modelsWithInput, target)};`);
|
|
141
155
|
}
|
|
142
156
|
if (modelsWithOutput?.has(model.name)) {
|
|
143
|
-
lines.push(`export type ${model.name}Output = ${renderOutputTsType(model.type!, modelsWithOutput)};`);
|
|
157
|
+
lines.push(`export type ${model.name}Output = ${renderOutputTsType(model.type!, modelsWithOutput, target)};`);
|
|
144
158
|
}
|
|
145
159
|
return lines;
|
|
146
160
|
}
|
|
@@ -158,7 +172,7 @@ function buildExtendsClause(bases: string[], overrideNames: string[], baseNameRe
|
|
|
158
172
|
return ` extends ${wrapped.join(', ')}`;
|
|
159
173
|
}
|
|
160
174
|
|
|
161
|
-
function generateSimpleModel(model: ModelNode, outPath?: string, modelMap?: Map<string, ModelNode>): string[] {
|
|
175
|
+
function generateSimpleModel(model: ModelNode, target: TsRenderTarget, outPath?: string, modelMap?: Map<string, ModelNode>): string[] {
|
|
162
176
|
const lines: string[] = [];
|
|
163
177
|
lines.push(...generateComments(model, outPath));
|
|
164
178
|
|
|
@@ -167,14 +181,20 @@ function generateSimpleModel(model: ModelNode, outPath?: string, modelMap?: Map<
|
|
|
167
181
|
lines.push(`export interface ${model.name}${buildExtendsClause(bases, overrideNames, b => b)} {`);
|
|
168
182
|
|
|
169
183
|
for (const field of model.fields) {
|
|
170
|
-
lines.push(` ${renderField(field)}`);
|
|
184
|
+
lines.push(` ${renderField(field, target)}`);
|
|
171
185
|
}
|
|
172
186
|
|
|
173
187
|
lines.push('}');
|
|
174
188
|
return lines;
|
|
175
189
|
}
|
|
176
190
|
|
|
177
|
-
function generateVisibilityModel(
|
|
191
|
+
function generateVisibilityModel(
|
|
192
|
+
model: ModelNode,
|
|
193
|
+
target: TsRenderTarget,
|
|
194
|
+
outPath?: string,
|
|
195
|
+
modelsWithInput?: Set<string>,
|
|
196
|
+
modelMap?: Map<string, ModelNode>,
|
|
197
|
+
): string[] {
|
|
178
198
|
const lines: string[] = [];
|
|
179
199
|
lines.push(...generateComments(model, outPath));
|
|
180
200
|
|
|
@@ -185,7 +205,7 @@ function generateVisibilityModel(model: ModelNode, outPath?: string, modelsWithI
|
|
|
185
205
|
const readFields = model.fields.filter(f => f.visibility !== 'writeonly');
|
|
186
206
|
lines.push(`export interface ${model.name}${buildExtendsClause(bases, overrideNames, b => b)} {`);
|
|
187
207
|
for (const field of readFields) {
|
|
188
|
-
lines.push(` ${renderField(field)}`);
|
|
208
|
+
lines.push(` ${renderField(field, target)}`);
|
|
189
209
|
}
|
|
190
210
|
lines.push('}');
|
|
191
211
|
lines.push('');
|
|
@@ -196,7 +216,7 @@ function generateVisibilityModel(model: ModelNode, outPath?: string, modelsWithI
|
|
|
196
216
|
const inputResolver = (b: string) => (modelsWithInput?.has(b) ? `${b}Input` : b);
|
|
197
217
|
lines.push(`export interface ${model.name}Input${buildExtendsClause(bases, overrideNames, inputResolver)} {`);
|
|
198
218
|
for (const field of writeFields) {
|
|
199
|
-
lines.push(` ${modelsWithInput ? renderInputField(field, modelsWithInput) : renderField(field)}`);
|
|
219
|
+
lines.push(` ${modelsWithInput ? renderInputField(field, modelsWithInput, target) : renderField(field, target)}`);
|
|
200
220
|
}
|
|
201
221
|
lines.push('}');
|
|
202
222
|
|
|
@@ -217,9 +237,9 @@ function withFieldJsDoc(jsdocParts: string[], line: string): string {
|
|
|
217
237
|
return `/**\n${body}\n */\n ${line}`;
|
|
218
238
|
}
|
|
219
239
|
|
|
220
|
-
function renderField(field: FieldNode): string {
|
|
240
|
+
function renderField(field: FieldNode, target: TsRenderTarget): string {
|
|
221
241
|
const opt = field.optional || field.default !== undefined ? '?' : '';
|
|
222
|
-
let typeStr = renderTsType(field.type);
|
|
242
|
+
let typeStr = renderTsType(field.type, target);
|
|
223
243
|
if (field.nullable) typeStr += ' | null';
|
|
224
244
|
const line = `${quoteKey(field.name)}${opt}: ${typeStr};`;
|
|
225
245
|
const jsdocParts: string[] = [];
|
|
@@ -228,9 +248,9 @@ function renderField(field: FieldNode): string {
|
|
|
228
248
|
return withFieldJsDoc(jsdocParts, line);
|
|
229
249
|
}
|
|
230
250
|
|
|
231
|
-
function renderInputField(field: FieldNode, modelsWithInput: Set<string
|
|
251
|
+
function renderInputField(field: FieldNode, modelsWithInput: Set<string>, target: TsRenderTarget): string {
|
|
232
252
|
const opt = field.optional || field.default !== undefined ? '?' : '';
|
|
233
|
-
let typeStr = renderInputTsType(field.type, modelsWithInput);
|
|
253
|
+
let typeStr = renderInputTsType(field.type, modelsWithInput, target);
|
|
234
254
|
if (field.nullable) typeStr += ' | null';
|
|
235
255
|
const line = `${quoteKey(field.name)}${opt}: ${typeStr};`;
|
|
236
256
|
const jsdocParts: string[] = [];
|
|
@@ -264,7 +284,7 @@ function applyOutputCase(name: string, c: 'camel' | 'snake' | 'pascal' | undefin
|
|
|
264
284
|
* ancestor has format(...) (see `flattenFormatChain` in codegen-contract); we mirror that here
|
|
265
285
|
* so the plain interface matches the wire shape produced by the Zod transform.
|
|
266
286
|
*/
|
|
267
|
-
function generateOutputModel(model: ModelNode, modelsWithOutput: Set<string>): string[] {
|
|
287
|
+
function generateOutputModel(model: ModelNode, target: TsRenderTarget, modelsWithOutput: Set<string>): string[] {
|
|
268
288
|
const lines: string[] = [];
|
|
269
289
|
const outputCase = model.outputCase && model.outputCase !== 'camel' ? model.outputCase : undefined;
|
|
270
290
|
const readFields = model.fields.filter(f => f.visibility !== 'writeonly');
|
|
@@ -279,7 +299,7 @@ function generateOutputModel(model: ModelNode, modelsWithOutput: Set<string>): s
|
|
|
279
299
|
: '';
|
|
280
300
|
lines.push(`export interface ${model.name}Output${baseExt} {`);
|
|
281
301
|
for (const field of readFields) {
|
|
282
|
-
lines.push(` ${renderOutputField(field, model.outputCase, modelsWithOutput)}`);
|
|
302
|
+
lines.push(` ${renderOutputField(field, model.outputCase, modelsWithOutput, target)}`);
|
|
283
303
|
}
|
|
284
304
|
lines.push('}');
|
|
285
305
|
return lines;
|
|
@@ -288,16 +308,21 @@ function generateOutputModel(model: ModelNode, modelsWithOutput: Set<string>): s
|
|
|
288
308
|
// Direct hit: emit a flat interface with renamed keys.
|
|
289
309
|
lines.push(`export interface ${model.name}Output {`);
|
|
290
310
|
for (const field of readFields) {
|
|
291
|
-
lines.push(` ${renderOutputField(field, outputCase, modelsWithOutput)}`);
|
|
311
|
+
lines.push(` ${renderOutputField(field, outputCase, modelsWithOutput, target)}`);
|
|
292
312
|
}
|
|
293
313
|
lines.push('}');
|
|
294
314
|
return lines;
|
|
295
315
|
}
|
|
296
316
|
|
|
297
|
-
function renderOutputField(
|
|
317
|
+
function renderOutputField(
|
|
318
|
+
field: FieldNode,
|
|
319
|
+
outputCase: 'camel' | 'snake' | 'pascal' | undefined,
|
|
320
|
+
modelsWithOutput: Set<string>,
|
|
321
|
+
target: TsRenderTarget,
|
|
322
|
+
): string {
|
|
298
323
|
const opt = field.optional || field.default !== undefined ? '?' : '';
|
|
299
324
|
const key = applyOutputCase(field.name, outputCase);
|
|
300
|
-
let typeStr = renderOutputTsType(field.type, modelsWithOutput);
|
|
325
|
+
let typeStr = renderOutputTsType(field.type, modelsWithOutput, target);
|
|
301
326
|
if (field.nullable) typeStr += ' | null';
|
|
302
327
|
const line = `${quoteKey(key)}${opt}: ${typeStr};`;
|
|
303
328
|
const jsdocParts: string[] = [];
|
package/src/index.ts
CHANGED
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
|
|
58
58
|
// ─── Sub-config interfaces ─────────────────────────────────────────────────
|
|
59
59
|
|
|
60
|
+
/** Koa server output: routers, and the type or Zod schema files they import. */
|
|
60
61
|
export interface ServerConfig {
|
|
61
62
|
/** Directory (relative to rootDir) where server files are written. Default: rootDir. */
|
|
62
63
|
baseDir?: string;
|
|
@@ -74,6 +75,7 @@ export interface ServerConfig {
|
|
|
74
75
|
includeInternal?: boolean;
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
/** TypeScript SDK client output: the client class, per-area operation clients, and their types. */
|
|
77
79
|
export interface SdkConfig {
|
|
78
80
|
baseDir?: string;
|
|
79
81
|
name?: string;
|
|
@@ -95,16 +97,25 @@ export interface SdkConfig {
|
|
|
95
97
|
scaffold?: boolean;
|
|
96
98
|
}
|
|
97
99
|
|
|
100
|
+
/** Standalone Zod schema output, independent of the server and SDK sub-generators. */
|
|
98
101
|
export interface ZodConfig {
|
|
99
102
|
baseDir?: string;
|
|
100
103
|
output?: string;
|
|
101
104
|
}
|
|
102
105
|
|
|
106
|
+
/** Standalone plain TypeScript type output, independent of the server and SDK sub-generators. */
|
|
103
107
|
export interface TypesConfig {
|
|
104
108
|
baseDir?: string;
|
|
105
109
|
output?: string;
|
|
110
|
+
/**
|
|
111
|
+
* Runtime the emitted types describe. Affects scalars whose TypeScript type is runtime-specific:
|
|
112
|
+
* `binary` renders as `Buffer` for `'server'` and `Blob` for `'client'`. Default `'client'`.
|
|
113
|
+
* The `server` and `sdk` sub-generators set this themselves.
|
|
114
|
+
*/
|
|
115
|
+
target?: 'client' | 'server';
|
|
106
116
|
}
|
|
107
117
|
|
|
118
|
+
/** MCP tool output: per-op-file handlers, the aggregator, and the optional POST route. */
|
|
108
119
|
export interface McpConfig {
|
|
109
120
|
/** Directory (relative to rootDir) where MCP files are written. Default: rootDir. */
|
|
110
121
|
baseDir?: string;
|
|
@@ -132,6 +143,7 @@ export interface McpConfig {
|
|
|
132
143
|
includeInternal?: boolean;
|
|
133
144
|
}
|
|
134
145
|
|
|
146
|
+
/** Top-level plugin config. Each sub-config that is present enables its sub-generator. */
|
|
135
147
|
export interface TypescriptPluginConfig {
|
|
136
148
|
server?: ServerConfig;
|
|
137
149
|
sdk?: SdkConfig;
|
|
@@ -355,6 +367,8 @@ function collectServerOutput(
|
|
|
355
367
|
currentOutPath: typeOutPath,
|
|
356
368
|
modelsWithInput,
|
|
357
369
|
modelsWithOutput,
|
|
370
|
+
// These types are consumed by Koa handlers, so `binary` is a Buffer, not a Blob.
|
|
371
|
+
target: 'server' as const,
|
|
358
372
|
};
|
|
359
373
|
const content = config.zod ? generateContract(ast, renderCtx) : generatePlainTypes(ast, renderCtx);
|
|
360
374
|
return [{ relativePath: typeOutPath, content }];
|
|
@@ -861,7 +875,13 @@ function collectTypesOutput(
|
|
|
861
875
|
render: () => [
|
|
862
876
|
{
|
|
863
877
|
relativePath: outPath,
|
|
864
|
-
content: generatePlainTypes(ast, {
|
|
878
|
+
content: generatePlainTypes(ast, {
|
|
879
|
+
modelOutPaths,
|
|
880
|
+
currentOutPath: outPath,
|
|
881
|
+
modelsWithInput,
|
|
882
|
+
modelsWithOutput,
|
|
883
|
+
target: config.target,
|
|
884
|
+
}),
|
|
865
885
|
},
|
|
866
886
|
],
|
|
867
887
|
});
|