@contractkit/plugin-typescript 0.29.0 → 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 +4 -4
- package/.turbo/turbo-test$colon$ci.log +14 -14
- package/CHANGELOG.md +13 -0
- package/dist/codegen-operation.d.ts +9 -1
- package/dist/codegen-operation.d.ts.map +1 -1
- package/dist/index.js +39 -65
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/codegen-operation.ts +46 -81
- package/tests/codegen-operation.test.ts +63 -3
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-operation.ts
CHANGED
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
renderInputType,
|
|
6
6
|
renderQueryType,
|
|
7
7
|
pascalToDotCase,
|
|
8
|
-
typeNeedsDateTime,
|
|
9
8
|
typeNeedsScalar,
|
|
10
9
|
modeToWrapper,
|
|
11
10
|
} from './codegen-contract.js';
|
|
@@ -126,50 +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
|
-
// luxon is needed for date/time/datetime (DateTime), duration (Duration) and interval (Interval);
|
|
157
|
-
// the rendered Zod schemas and the service-result annotations both reference these classes.
|
|
158
|
-
const luxonImports: string[] = [];
|
|
159
|
-
if (opNeedsDateTime(root)) luxonImports.push('DateTime');
|
|
160
|
-
if (opNeedsScalar(root, 'duration')) luxonImports.push('Duration');
|
|
161
|
-
if (opNeedsScalar(root, 'interval')) luxonImports.push('Interval');
|
|
162
|
-
if (luxonImports.length > 0) {
|
|
163
|
-
body.push(`import { ${luxonImports.join(', ')} } from 'luxon';`);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
if (needsParseAndValidate) {
|
|
167
|
-
body.push(`import { parseAndValidate } from '@maroonedsoftware/zod';`);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
if (fileUsesMultipart(root)) {
|
|
171
|
-
body.push(`import { MultipartBody } from '@maroonedsoftware/multipart';`);
|
|
172
|
-
}
|
|
173
142
|
|
|
174
143
|
const helpers: string[] = [];
|
|
175
144
|
if (opNeedsScalar(root, 'binary')) {
|
|
@@ -211,6 +180,43 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
|
|
|
211
180
|
}
|
|
212
181
|
}
|
|
213
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
|
+
|
|
214
220
|
const allContent = [...body, ...(helpers.length ? ['', ...helpers] : []), ...lines].join('\n');
|
|
215
221
|
const needsZod = /\bz\./.test(allContent);
|
|
216
222
|
return (needsZod ? `import { z } from 'zod';\n` : '') + allContent;
|
|
@@ -780,26 +786,7 @@ function collectTypeNodeRefs(type: ContractTypeNode, out: Set<string>): void {
|
|
|
780
786
|
}
|
|
781
787
|
}
|
|
782
788
|
|
|
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
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
790
|
|
|
804
791
|
function paramSourceNeedsScalar(source: ParamSource | undefined, name: string): boolean {
|
|
805
792
|
if (!source) return false;
|
|
@@ -838,32 +825,10 @@ function collectServices(root: OpRootNode): string[] {
|
|
|
838
825
|
return [...services].sort();
|
|
839
826
|
}
|
|
840
827
|
|
|
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
828
|
|
|
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
829
|
|
|
854
|
-
function fileNeedsPolicy(root: OpRootNode): boolean {
|
|
855
|
-
return root.routes.some(route => route.operations.some(op => resolveSecurity(route, op, root) !== SECURITY_NONE));
|
|
856
|
-
}
|
|
857
830
|
|
|
858
|
-
function fileNeedsSignature(root: OpRootNode): boolean {
|
|
859
|
-
return root.routes.some(route => route.operations.some(op => !!op.signature));
|
|
860
|
-
}
|
|
861
831
|
|
|
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
832
|
|
|
868
833
|
function isValidIdentifier(name: string): boolean {
|
|
869
834
|
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name);
|
|
@@ -101,6 +101,66 @@ describe('generateOperation', () => {
|
|
|
101
101
|
const output = generateOp(root);
|
|
102
102
|
expect(output).not.toContain('luxon');
|
|
103
103
|
});
|
|
104
|
+
|
|
105
|
+
// Every conditional import must be justified by a reference in the generated body —
|
|
106
|
+
// an unused import trips `noUnusedLocals` and lint in the consuming project.
|
|
107
|
+
it('imports bodyParserMiddleware only when an operation has a request body', () => {
|
|
108
|
+
const withBody = generateOp(opRoot([opRoute('/users', [opOperation('post', { request: opRequest('CreateUser') })])]));
|
|
109
|
+
expect(withBody).toContain('bodyParserMiddleware');
|
|
110
|
+
|
|
111
|
+
const withoutBody = generateOp(opRoot([opRoute('/users', [opOperation('get')])]));
|
|
112
|
+
expect(withoutBody).not.toContain('bodyParserMiddleware');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('omits MultipartBody when a multipart body shares its shape with the other MIME types', () => {
|
|
116
|
+
// Structurally equal bodies collapse to a single parseAndValidate call, so nothing
|
|
117
|
+
// references MultipartBody even though the operation does declare multipart.
|
|
118
|
+
const root = opRoot([
|
|
119
|
+
opRoute('/upload', [
|
|
120
|
+
opOperation('post', {
|
|
121
|
+
request: opMultiRequest([
|
|
122
|
+
['multipart/form-data', 'UploadForm'],
|
|
123
|
+
['application/json', 'UploadForm'],
|
|
124
|
+
]),
|
|
125
|
+
}),
|
|
126
|
+
]),
|
|
127
|
+
]);
|
|
128
|
+
const output = generateOp(root);
|
|
129
|
+
expect(output).not.toContain('MultipartBody');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('imports MultipartBody when the multipart body is handled on its own', () => {
|
|
133
|
+
const root = opRoot([opRoute('/upload', [opOperation('post', { request: opMultiRequest([['multipart/form-data', 'UploadForm']]) })])]);
|
|
134
|
+
expect(generateOp(root)).toContain("import { MultipartBody } from '@maroonedsoftware/multipart';");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('leaves no import unreferenced in the generated body', () => {
|
|
138
|
+
const root = opRoot([
|
|
139
|
+
opRoute(
|
|
140
|
+
'/users/{id}',
|
|
141
|
+
[
|
|
142
|
+
opOperation('get', { security: SECURITY_NONE }),
|
|
143
|
+
opOperation('post', { request: opRequest('CreateUser'), signature: 'webhookKey' }),
|
|
144
|
+
],
|
|
145
|
+
[opParam('id', scalarType('uuid'))],
|
|
146
|
+
),
|
|
147
|
+
]);
|
|
148
|
+
const output = generateOp(root);
|
|
149
|
+
const importLines = output.split('\n').filter(l => l.startsWith('import '));
|
|
150
|
+
expect(importLines.length).toBeGreaterThan(0);
|
|
151
|
+
|
|
152
|
+
const bodyText = output
|
|
153
|
+
.split('\n')
|
|
154
|
+
.filter(l => !l.startsWith('import '))
|
|
155
|
+
.join('\n');
|
|
156
|
+
for (const line of importLines) {
|
|
157
|
+
const named = line.match(/^import \{([^}]*)\}/);
|
|
158
|
+
if (!named) continue;
|
|
159
|
+
for (const symbol of named[1]!.split(',').map(s => s.trim().replace(/^type /, ''))) {
|
|
160
|
+
expect(bodyText, `${symbol} is imported but never used`).toMatch(new RegExp(`\\b${symbol}\\b`));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
104
164
|
});
|
|
105
165
|
|
|
106
166
|
// ─── Handler signature ─────────────────────────────────────────
|
|
@@ -974,7 +1034,7 @@ describe('generateOp — route modifiers JSDoc', () => {
|
|
|
974
1034
|
const root = opRoot([opRoute('/users', [op])]);
|
|
975
1035
|
const out = generateOp(root);
|
|
976
1036
|
expect(out).not.toContain('requireSignature');
|
|
977
|
-
expect(out).toContain(`import { ServerKitRouter,
|
|
1037
|
+
expect(out).toContain(`import { ServerKitRouter, requirePolicy }`);
|
|
978
1038
|
});
|
|
979
1039
|
});
|
|
980
1040
|
|
|
@@ -985,7 +1045,7 @@ describe('generateOp — route modifiers JSDoc', () => {
|
|
|
985
1045
|
const op = opOperation('get');
|
|
986
1046
|
const root = opRoot([opRoute('/users', [op])]);
|
|
987
1047
|
const out = generateOp(root);
|
|
988
|
-
expect(out).toContain(`import { ServerKitRouter,
|
|
1048
|
+
expect(out).toContain(`import { ServerKitRouter, requirePolicy }`);
|
|
989
1049
|
expect(out).toContain(`requirePolicy()`);
|
|
990
1050
|
});
|
|
991
1051
|
|
|
@@ -1019,7 +1079,7 @@ describe('generateOp — route modifiers JSDoc', () => {
|
|
|
1019
1079
|
const op = opOperation('get', { security: SECURITY_NONE });
|
|
1020
1080
|
const root = opRoot([opRoute('/health', [op])]);
|
|
1021
1081
|
const out = generateOp(root);
|
|
1022
|
-
expect(out).toContain(`import { ServerKitRouter
|
|
1082
|
+
expect(out).toContain(`import { ServerKitRouter } from`);
|
|
1023
1083
|
expect(out).not.toContain('requirePolicy');
|
|
1024
1084
|
});
|
|
1025
1085
|
|