@contractkit/plugin-typescript 0.16.1
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 +35 -0
- package/.turbo/turbo-build.log +15 -0
- package/.turbo/turbo-test$colon$ci.log +81 -0
- package/.turbo/turbo-test.log +19 -0
- package/CHANGELOG.md +151 -0
- package/README.md +153 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/clover.xml +1882 -0
- package/coverage/coverage-final.json +9 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +131 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/codegen-contract.ts.html +3331 -0
- package/coverage/src/codegen-operation.ts.html +2530 -0
- package/coverage/src/codegen-plain-types.ts.html +901 -0
- package/coverage/src/codegen-sdk.ts.html +2797 -0
- package/coverage/src/index.html +206 -0
- package/coverage/src/index.ts.html +1360 -0
- package/coverage/src/path-utils.ts.html +649 -0
- package/coverage/src/ts-render.ts.html +592 -0
- package/coverage/tests/helpers.ts.html +826 -0
- package/coverage/tests/index.html +116 -0
- package/dist/codegen-contract.d.ts +56 -0
- package/dist/codegen-contract.d.ts.map +1 -0
- package/dist/codegen-operation.d.ts +25 -0
- package/dist/codegen-operation.d.ts.map +1 -0
- package/dist/codegen-plain-types.d.ts +10 -0
- package/dist/codegen-plain-types.d.ts.map +1 -0
- package/dist/codegen-sdk.d.ts +38 -0
- package/dist/codegen-sdk.d.ts.map +1 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3162 -0
- package/dist/index.js.map +1 -0
- package/dist/path-utils.d.ts +15 -0
- package/dist/path-utils.d.ts.map +1 -0
- package/dist/ts-render.d.ts +20 -0
- package/dist/ts-render.d.ts.map +1 -0
- package/eslint.config.js +6 -0
- package/package.json +43 -0
- package/src/codegen-contract.ts +1082 -0
- package/src/codegen-operation.ts +815 -0
- package/src/codegen-plain-types.ts +272 -0
- package/src/codegen-sdk.ts +904 -0
- package/src/index.ts +425 -0
- package/src/path-utils.ts +188 -0
- package/src/ts-render.ts +169 -0
- package/tests/codegen-contract.test.ts +1004 -0
- package/tests/codegen-operation.test.ts +939 -0
- package/tests/codegen-plain-types.test.ts +636 -0
- package/tests/codegen-sdk.test.ts +1500 -0
- package/tests/codegen-server.test.ts +192 -0
- package/tests/helpers.ts +247 -0
- package/tests/pipeline.test.ts +372 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
import type { OpRootNode, OpRouteNode, OpOperationNode, ContractTypeNode, ParamSource, ObjectMode } from '@contractkit/core';
|
|
2
|
+
import { resolveModifiers, resolveSecurity, SECURITY_NONE, classifyContentType } from '@contractkit/core';
|
|
3
|
+
import {
|
|
4
|
+
renderType,
|
|
5
|
+
renderInputType,
|
|
6
|
+
renderQueryType,
|
|
7
|
+
pascalToDotCase,
|
|
8
|
+
typeNeedsDateTime,
|
|
9
|
+
typeNeedsScalar,
|
|
10
|
+
modeToWrapper,
|
|
11
|
+
} from './codegen-contract.js';
|
|
12
|
+
import { renderOutputTsType, quoteKey, headerNameToProperty } from './ts-render.js';
|
|
13
|
+
import { basename, dirname, relative } from 'path';
|
|
14
|
+
|
|
15
|
+
// ─── Content-type helpers ──────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/** Map a request MIME type to the koa-bodyparser parser token used in middleware. */
|
|
18
|
+
function bodyParserToken(contentType: string): string {
|
|
19
|
+
switch (classifyContentType(contentType)) {
|
|
20
|
+
case 'urlencoded':
|
|
21
|
+
return 'urlencoded';
|
|
22
|
+
case 'multipart':
|
|
23
|
+
return 'multipart';
|
|
24
|
+
case 'text':
|
|
25
|
+
return 'text';
|
|
26
|
+
case 'binary':
|
|
27
|
+
// koa-bodyparser has no native binary token; fall back to text so the body is
|
|
28
|
+
// still readable as a string. Services handling binary uploads should switch to
|
|
29
|
+
// multipart/form-data.
|
|
30
|
+
return 'text';
|
|
31
|
+
default:
|
|
32
|
+
return 'json';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Deep structural equality on ContractTypeNode, ignoring source locations on inline fields.
|
|
38
|
+
* Used to decide whether multiple declared request MIMEs can share a single validate path.
|
|
39
|
+
*/
|
|
40
|
+
export function bodyTypesStructurallyEqual(a: ContractTypeNode, b: ContractTypeNode): boolean {
|
|
41
|
+
if (a.kind !== b.kind) return false;
|
|
42
|
+
switch (a.kind) {
|
|
43
|
+
case 'scalar': {
|
|
44
|
+
const bb = b as typeof a;
|
|
45
|
+
return a.name === bb.name && a.min === bb.min && a.max === bb.max && a.len === bb.len && a.regex === bb.regex && a.format === bb.format;
|
|
46
|
+
}
|
|
47
|
+
case 'array': {
|
|
48
|
+
const bb = b as typeof a;
|
|
49
|
+
return a.min === bb.min && a.max === bb.max && bodyTypesStructurallyEqual(a.item, bb.item);
|
|
50
|
+
}
|
|
51
|
+
case 'tuple': {
|
|
52
|
+
const bb = b as typeof a;
|
|
53
|
+
return a.items.length === bb.items.length && a.items.every((x, i) => bodyTypesStructurallyEqual(x, bb.items[i]!));
|
|
54
|
+
}
|
|
55
|
+
case 'record': {
|
|
56
|
+
const bb = b as typeof a;
|
|
57
|
+
return bodyTypesStructurallyEqual(a.key, bb.key) && bodyTypesStructurallyEqual(a.value, bb.value);
|
|
58
|
+
}
|
|
59
|
+
case 'enum': {
|
|
60
|
+
const bb = b as typeof a;
|
|
61
|
+
return a.values.length === bb.values.length && a.values.every((v, i) => v === bb.values[i]);
|
|
62
|
+
}
|
|
63
|
+
case 'literal': {
|
|
64
|
+
const bb = b as typeof a;
|
|
65
|
+
return a.value === bb.value;
|
|
66
|
+
}
|
|
67
|
+
case 'union':
|
|
68
|
+
case 'intersection': {
|
|
69
|
+
const bb = b as typeof a;
|
|
70
|
+
return a.members.length === bb.members.length && a.members.every((m, i) => bodyTypesStructurallyEqual(m, bb.members[i]!));
|
|
71
|
+
}
|
|
72
|
+
case 'discriminatedUnion': {
|
|
73
|
+
const bb = b as typeof a;
|
|
74
|
+
return (
|
|
75
|
+
a.discriminator === bb.discriminator &&
|
|
76
|
+
a.members.length === bb.members.length &&
|
|
77
|
+
a.members.every((m, i) => bodyTypesStructurallyEqual(m, bb.members[i]!))
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
case 'ref': {
|
|
81
|
+
const bb = b as typeof a;
|
|
82
|
+
return a.name === bb.name && !!a.lazy === !!bb.lazy;
|
|
83
|
+
}
|
|
84
|
+
case 'lazy': {
|
|
85
|
+
const bb = b as typeof a;
|
|
86
|
+
return bodyTypesStructurallyEqual(a.inner, bb.inner);
|
|
87
|
+
}
|
|
88
|
+
case 'inlineObject': {
|
|
89
|
+
const bb = b as typeof a;
|
|
90
|
+
if (a.mode !== bb.mode) return false;
|
|
91
|
+
if (a.fields.length !== bb.fields.length) return false;
|
|
92
|
+
return a.fields.every((f, i) => {
|
|
93
|
+
const g = bb.fields[i]!;
|
|
94
|
+
return (
|
|
95
|
+
f.name === g.name &&
|
|
96
|
+
f.optional === g.optional &&
|
|
97
|
+
f.nullable === g.nullable &&
|
|
98
|
+
f.visibility === g.visibility &&
|
|
99
|
+
f.default === g.default &&
|
|
100
|
+
!!f.deprecated === !!g.deprecated &&
|
|
101
|
+
bodyTypesStructurallyEqual(f.type, g.type)
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── Public entry point ────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
export interface OpCodegenOptions {
|
|
111
|
+
servicePathTemplate?: string;
|
|
112
|
+
typeImportPathTemplate?: string;
|
|
113
|
+
outPath?: string;
|
|
114
|
+
/** Map from model name → absolute output file path (for cross-module type imports) */
|
|
115
|
+
modelOutPaths?: Map<string, string>;
|
|
116
|
+
/** Set of model names that have Input variants (models with visibility modifiers) */
|
|
117
|
+
modelsWithInput?: Set<string>;
|
|
118
|
+
/** Set of model names that have Output variants (models with format(output=...)) */
|
|
119
|
+
modelsWithOutput?: Set<string>;
|
|
120
|
+
/**
|
|
121
|
+
* Whether to emit handlers for operations marked `internal`. Defaults to `true` because
|
|
122
|
+
* the server still needs routes for internal endpoints; set to `false` to omit them
|
|
123
|
+
* from the generated router entirely.
|
|
124
|
+
*/
|
|
125
|
+
includeInternal?: boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): string {
|
|
129
|
+
// Collect all referenced types across all routes
|
|
130
|
+
const types = collectTypes(root, options.modelsWithInput, options.modelsWithOutput);
|
|
131
|
+
const services = collectServices(root);
|
|
132
|
+
const routerName = deriveRouterName(root.file);
|
|
133
|
+
const needsParseAndValidate = routeNeedsValidation(root);
|
|
134
|
+
|
|
135
|
+
// Generate the body first so we can detect whether `z.` is actually referenced
|
|
136
|
+
// before deciding whether to emit the zod import.
|
|
137
|
+
const body: string[] = [];
|
|
138
|
+
const needsSignature = fileNeedsSignature(root);
|
|
139
|
+
const needsSecurity = fileNeedsSecurity(root);
|
|
140
|
+
const koaImports = ['ServerKitRouter', 'bodyParserMiddleware'];
|
|
141
|
+
if (needsSecurity) koaImports.push('requireSecurity');
|
|
142
|
+
if (needsSignature) koaImports.push('requireSignature');
|
|
143
|
+
body.push(`import { ${koaImports.join(', ')} } from '@maroonedsoftware/koa';`);
|
|
144
|
+
|
|
145
|
+
for (const svc of services) {
|
|
146
|
+
const modulePath = root.services?.[svc] ?? root.meta[svc] ?? deriveModulePath(svc, options.servicePathTemplate);
|
|
147
|
+
body.push(`import { ${svc} } from '${modulePath}';`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (types.length > 0) {
|
|
151
|
+
body.push(...generateTypeImports(types, root.file, options));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (opNeedsDateTime(root)) {
|
|
155
|
+
body.push(`import { DateTime } from 'luxon';`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (needsParseAndValidate) {
|
|
159
|
+
body.push(`import { parseAndValidate } from '@maroonedsoftware/zod';`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const helpers: string[] = [];
|
|
163
|
+
if (opNeedsScalar(root, 'binary')) {
|
|
164
|
+
helpers.push(`const _ZodBinary = z.custom<Buffer>((val) => Buffer.isBuffer(val), { error: 'Must be binary data' });`);
|
|
165
|
+
}
|
|
166
|
+
if (opNeedsScalar(root, 'datetime')) {
|
|
167
|
+
helpers.push(
|
|
168
|
+
`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' }));`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (opNeedsScalar(root, 'json')) {
|
|
172
|
+
helpers.push(`type _JsonValue = string | number | boolean | null | _JsonValue[] | { [key: string]: _JsonValue };`);
|
|
173
|
+
helpers.push(
|
|
174
|
+
`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)]));`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const lines: string[] = [];
|
|
179
|
+
|
|
180
|
+
lines.push('');
|
|
181
|
+
lines.push('/**');
|
|
182
|
+
const relFile = options.outPath ? relative(dirname(options.outPath), root.file) : root.file;
|
|
183
|
+
lines.push(` * generated from [${basename(root.file)}](file://./${relFile})`);
|
|
184
|
+
lines.push('*/');
|
|
185
|
+
lines.push(`export const ${routerName} = ServerKitRouter();`);
|
|
186
|
+
lines.push('');
|
|
187
|
+
|
|
188
|
+
const includeInternal = options.includeInternal ?? true;
|
|
189
|
+
for (const route of root.routes) {
|
|
190
|
+
for (const op of route.operations) {
|
|
191
|
+
if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
|
|
192
|
+
lines.push(...generateHandler(route, op, root, options));
|
|
193
|
+
lines.push('');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const allContent = [...body, ...(helpers.length ? ['', ...helpers] : []), ...lines].join('\n');
|
|
198
|
+
const needsZod = /\bz\./.test(allContent);
|
|
199
|
+
return (needsZod ? `import { z } from 'zod';\n` : '') + allContent;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── Handler generation ────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNode, options: OpCodegenOptions): string[] {
|
|
205
|
+
const lines: string[] = [];
|
|
206
|
+
const file = root.file;
|
|
207
|
+
const outPath = options.outPath;
|
|
208
|
+
const modelsWithInput = options.modelsWithInput;
|
|
209
|
+
|
|
210
|
+
lines.push('/**');
|
|
211
|
+
|
|
212
|
+
// JSDoc from description
|
|
213
|
+
const desc = op.description ?? route.description;
|
|
214
|
+
if (desc) {
|
|
215
|
+
lines.push(` * ${desc}`);
|
|
216
|
+
}
|
|
217
|
+
// Source location comment
|
|
218
|
+
const relFile = outPath ? relative(dirname(outPath), file) : file;
|
|
219
|
+
lines.push(` * from [${basename(file)}](file://./${relFile}#L${op.loc.line})`);
|
|
220
|
+
|
|
221
|
+
// Security annotation (operation-level wins; falls back to route → file level)
|
|
222
|
+
const effectiveSecurity = resolveSecurity(route, op, root);
|
|
223
|
+
if (effectiveSecurity === SECURITY_NONE) {
|
|
224
|
+
lines.push(` * anonymous access, no security required`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Modifier annotations
|
|
228
|
+
const mods = resolveModifiers(route, op);
|
|
229
|
+
if (mods.includes('internal')) lines.push(` * @internal`);
|
|
230
|
+
if (mods.includes('deprecated')) lines.push(` * @deprecated`);
|
|
231
|
+
|
|
232
|
+
lines.push('*/');
|
|
233
|
+
|
|
234
|
+
const method = op.method;
|
|
235
|
+
const path = route.path.replace(/\{(\w+)\}/g, ':$1');
|
|
236
|
+
const bodies = op.request?.bodies ?? [];
|
|
237
|
+
const hasBody = bodies.length > 0;
|
|
238
|
+
const isSingleMultipart = bodies.length === 1 && bodies[0]!.contentType === 'multipart/form-data';
|
|
239
|
+
|
|
240
|
+
// Middleware list
|
|
241
|
+
const middlewares: string[] = [];
|
|
242
|
+
if (effectiveSecurity !== SECURITY_NONE) {
|
|
243
|
+
const roles = effectiveSecurity && effectiveSecurity.roles?.length ? `roles: [${effectiveSecurity.roles.map(r => `'${r}'`).join(', ')}]` : '';
|
|
244
|
+
middlewares.push(`requireSecurity({ ${roles} })`);
|
|
245
|
+
}
|
|
246
|
+
if (hasBody) {
|
|
247
|
+
const parserTokens = Array.from(new Set(bodies.map(b => bodyParserToken(b.contentType))));
|
|
248
|
+
const tokensExpr = parserTokens.map(t => `'${t}'`).join(', ');
|
|
249
|
+
middlewares.push(`bodyParserMiddleware([${tokensExpr}])`);
|
|
250
|
+
}
|
|
251
|
+
if (op.signature) {
|
|
252
|
+
middlewares.push(`requireSignature('${op.signature}')`);
|
|
253
|
+
}
|
|
254
|
+
const middlewareStr = middlewares.length > 0 ? `, ${middlewares.join(', ')},` : ',';
|
|
255
|
+
|
|
256
|
+
lines.push(`${deriveRouterName(file)}.${method}('${path}'${middlewareStr} async (ctx, next) => {`);
|
|
257
|
+
|
|
258
|
+
// Params / query / headers validation (request-side — use Input variants)
|
|
259
|
+
lines.push(...generateParamValidation(route.params, 'ctx.params', 'params', route.paramsMode ?? 'strict', '', modelsWithInput));
|
|
260
|
+
lines.push(...generateParamValidation(op.query, 'ctx.query', 'query', op.queryMode ?? 'strict', '', modelsWithInput));
|
|
261
|
+
lines.push(...generateParamValidation(op.headers, 'ctx.headers', 'headers', op.headersMode ?? 'strip', '', modelsWithInput));
|
|
262
|
+
|
|
263
|
+
// Body validation (request-side — use Input variants)
|
|
264
|
+
if (hasBody && op.request) {
|
|
265
|
+
if (isSingleMultipart) {
|
|
266
|
+
lines.push(` const multipartBody = ctx.body as MultipartBody;`);
|
|
267
|
+
lines.push('');
|
|
268
|
+
} else if (bodies.length === 1) {
|
|
269
|
+
lines.push(` const body = await parseAndValidate(ctx.body, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
|
|
270
|
+
lines.push('');
|
|
271
|
+
} else if (bodies.every(b => bodyTypesStructurallyEqual(b.bodyType, bodies[0]!.bodyType))) {
|
|
272
|
+
// All declared MIMEs share the same body shape — single validation suffices
|
|
273
|
+
lines.push(` const body = await parseAndValidate(ctx.body, ${renderInputType(bodies[0]!.bodyType, modelsWithInput)});`);
|
|
274
|
+
lines.push('');
|
|
275
|
+
} else {
|
|
276
|
+
// Different body types per MIME — dispatch on Content-Type
|
|
277
|
+
const annotation = bodies
|
|
278
|
+
.map(b =>
|
|
279
|
+
b.contentType === 'multipart/form-data' ? 'MultipartBody' : `z.infer<typeof ${renderInputType(b.bodyType, modelsWithInput)}>`,
|
|
280
|
+
)
|
|
281
|
+
.join(' | ');
|
|
282
|
+
lines.push(` let body!: ${annotation};`);
|
|
283
|
+
lines.push(` switch (ctx.request.type) {`);
|
|
284
|
+
for (const b of bodies) {
|
|
285
|
+
lines.push(` case '${b.contentType}':`);
|
|
286
|
+
if (b.contentType === 'multipart/form-data') {
|
|
287
|
+
lines.push(` body = ctx.body as MultipartBody;`);
|
|
288
|
+
} else {
|
|
289
|
+
lines.push(` body = await parseAndValidate(ctx.body, ${renderInputType(b.bodyType, modelsWithInput)});`);
|
|
290
|
+
}
|
|
291
|
+
lines.push(` break;`);
|
|
292
|
+
}
|
|
293
|
+
lines.push(` }`);
|
|
294
|
+
lines.push('');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Service call — use the first response with a body as the primary response
|
|
299
|
+
const primaryResponse = op.responses.find(r => r.bodyType) ?? op.responses[0];
|
|
300
|
+
const serviceParts = inferService(op, route, file);
|
|
301
|
+
const respHeaders = primaryResponse?.headers ?? [];
|
|
302
|
+
const hasRespHeaders = respHeaders.length > 0;
|
|
303
|
+
const headersAnnotation = hasRespHeaders
|
|
304
|
+
? `{ ${respHeaders
|
|
305
|
+
.map(h => `${quoteKey(headerNameToProperty(h.name))}${h.optional ? '?' : ''}: ${renderOutputTsType(h.type, options.modelsWithOutput)}`)
|
|
306
|
+
.join('; ')} }`
|
|
307
|
+
: '';
|
|
308
|
+
|
|
309
|
+
if (primaryResponse?.bodyType) {
|
|
310
|
+
const { annotation, prelude } = formatTypeAnnotation(primaryResponse.bodyType!, options.modelsWithOutput);
|
|
311
|
+
if (prelude) {
|
|
312
|
+
lines.push(` ${prelude}`);
|
|
313
|
+
}
|
|
314
|
+
lines.push(` const service = ctx.container.get(${serviceParts.className});`);
|
|
315
|
+
if (hasRespHeaders) {
|
|
316
|
+
lines.push(
|
|
317
|
+
` const result: { body: ${annotation}; headers: ${headersAnnotation} } = await service.${serviceParts.methodName}(${buildArgs(route, op)});`,
|
|
318
|
+
);
|
|
319
|
+
} else {
|
|
320
|
+
lines.push(` const result: ${annotation} = await service.${serviceParts.methodName}(${buildArgs(route, op)});`);
|
|
321
|
+
}
|
|
322
|
+
} else {
|
|
323
|
+
lines.push(` const service = ctx.container.get(${serviceParts.className});`);
|
|
324
|
+
if (hasRespHeaders) {
|
|
325
|
+
lines.push(` const result: { headers: ${headersAnnotation} } = await service.${serviceParts.methodName}(${buildArgs(route, op)});`);
|
|
326
|
+
} else {
|
|
327
|
+
lines.push(` await service.${serviceParts.methodName}(${buildArgs(route, op)});`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
lines.push('');
|
|
332
|
+
lines.push(` ctx.status = ${primaryResponse?.statusCode ?? 200};`);
|
|
333
|
+
|
|
334
|
+
if (hasRespHeaders) {
|
|
335
|
+
for (const h of respHeaders) {
|
|
336
|
+
const accessor = `result.headers[${JSON.stringify(headerNameToProperty(h.name))}]`;
|
|
337
|
+
if (h.optional) {
|
|
338
|
+
lines.push(` if (${accessor} !== undefined) ctx.set('${h.name}', String(${accessor}));`);
|
|
339
|
+
} else {
|
|
340
|
+
lines.push(` ctx.set('${h.name}', String(${accessor}));`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (primaryResponse?.bodyType && primaryResponse.contentType) {
|
|
346
|
+
lines.push(` ctx.type = '${primaryResponse.contentType}';`);
|
|
347
|
+
lines.push(` ctx.body = ${hasRespHeaders ? 'result.body' : 'result'};`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
lines.push('');
|
|
351
|
+
lines.push(` await next();`);
|
|
352
|
+
lines.push(`});`);
|
|
353
|
+
|
|
354
|
+
return lines;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ─── Inference helpers ─────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
function inferService(op: OpOperationNode, route: OpRouteNode, file: string): { className: string; methodName: string } {
|
|
360
|
+
// If explicitly declared: service: ServiceClass.methodName
|
|
361
|
+
if (op.service) {
|
|
362
|
+
const [cls = '', method] = op.service.split('.');
|
|
363
|
+
return { className: cls, methodName: method ?? 'handle' };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Infer from file name + method + path
|
|
367
|
+
const baseName = deriveBaseName(file); // e.g. "ledger.categories" -> "LedgerCategories"
|
|
368
|
+
const className = `${baseName}Service`;
|
|
369
|
+
const methodName = inferMethodName(op.method, route.path);
|
|
370
|
+
return { className, methodName };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function inferMethodName(method: string, path: string): string {
|
|
374
|
+
const hasParam = path.includes('{');
|
|
375
|
+
switch (method) {
|
|
376
|
+
case 'get':
|
|
377
|
+
return hasParam ? 'getById' : 'list';
|
|
378
|
+
case 'post':
|
|
379
|
+
return 'create';
|
|
380
|
+
case 'put':
|
|
381
|
+
return 'replace';
|
|
382
|
+
case 'patch':
|
|
383
|
+
return 'update';
|
|
384
|
+
case 'delete':
|
|
385
|
+
return 'delete';
|
|
386
|
+
default:
|
|
387
|
+
return 'handle';
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function buildArgs(route: OpRouteNode, op: OpOperationNode): string {
|
|
392
|
+
const args: string[] = [];
|
|
393
|
+
// Path params: spread individually (inline) or pass 'params' object (type-ref/ContractTypeNode)
|
|
394
|
+
if (route.params) {
|
|
395
|
+
if (route.params.kind === 'params') {
|
|
396
|
+
args.push(...route.params.nodes.map(p => p.name));
|
|
397
|
+
} else {
|
|
398
|
+
args.push('params');
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// Body
|
|
402
|
+
if (op.request && op.request.bodies.length > 0) {
|
|
403
|
+
const bodies = op.request.bodies;
|
|
404
|
+
const isSingleMultipart = bodies.length === 1 && bodies[0]!.contentType === 'multipart/form-data';
|
|
405
|
+
args.push(isSingleMultipart ? 'multipartBody' : 'body');
|
|
406
|
+
}
|
|
407
|
+
// Query
|
|
408
|
+
if (op.query) args.push('query');
|
|
409
|
+
// Headers
|
|
410
|
+
if (op.headers) args.push('headers');
|
|
411
|
+
return args.join(', ');
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function formatTypeAnnotation(bodyType: ContractTypeNode, modelsWithOutput?: Set<string>): { annotation: string; prelude?: string } {
|
|
415
|
+
if (bodyType.kind === 'array') {
|
|
416
|
+
const inner = formatTypeAnnotation(bodyType.item, modelsWithOutput);
|
|
417
|
+
return { annotation: `${inner.annotation}[]`, prelude: inner.prelude };
|
|
418
|
+
}
|
|
419
|
+
if (bodyType.kind === 'ref') {
|
|
420
|
+
const name = modelsWithOutput?.has(bodyType.name) ? `${bodyType.name}Output` : bodyType.name;
|
|
421
|
+
return { annotation: name };
|
|
422
|
+
}
|
|
423
|
+
if (bodyType.kind === 'scalar') return { annotation: bodyType.name };
|
|
424
|
+
// For complex types, extract schema into a variable so the result line stays readable
|
|
425
|
+
const schema = renderType(bodyType);
|
|
426
|
+
return {
|
|
427
|
+
annotation: 'z.infer<typeof resultType>',
|
|
428
|
+
prelude: `const resultType = ${schema};`,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function generateParamValidation(
|
|
433
|
+
source: ParamSource | undefined,
|
|
434
|
+
ctxExpr: string,
|
|
435
|
+
varName: string,
|
|
436
|
+
mode: ObjectMode,
|
|
437
|
+
suffix = '',
|
|
438
|
+
modelsWithInput?: Set<string>,
|
|
439
|
+
): string[] {
|
|
440
|
+
if (!source) return [];
|
|
441
|
+
const lines: string[] = [];
|
|
442
|
+
const isQuery = ctxExpr === 'ctx.query';
|
|
443
|
+
if (source.kind === 'ref') {
|
|
444
|
+
// Type reference — apply mode as a method call on the schema
|
|
445
|
+
const typeName = modelsWithInput?.has(source.name) ? `${source.name}Input` : source.name;
|
|
446
|
+
lines.push(` const ${varName} = await parseAndValidate(${ctxExpr}, ${typeName}.${mode}());`);
|
|
447
|
+
lines.push('');
|
|
448
|
+
} else if (source.kind === 'params') {
|
|
449
|
+
// Inline param declarations — wrap with the appropriate z.*Object constructor
|
|
450
|
+
if (source.nodes.length > 0) {
|
|
451
|
+
// Destructure only for params (spread individually in service call);
|
|
452
|
+
// query/headers are passed as whole objects.
|
|
453
|
+
const lhs = varName === 'params' ? `{ ${source.nodes.map(p => p.name).join(', ')} }` : varName;
|
|
454
|
+
lines.push(` const ${lhs} = await parseAndValidate(`);
|
|
455
|
+
lines.push(` ${ctxExpr},`);
|
|
456
|
+
lines.push(` ${modeToWrapper(mode)}({`);
|
|
457
|
+
for (const param of source.nodes) {
|
|
458
|
+
const key = isValidIdentifier(param.name) ? param.name : `'${param.name}'`;
|
|
459
|
+
if (isQuery && param.type.kind === 'array') {
|
|
460
|
+
const inner = renderType(param.type);
|
|
461
|
+
lines.push(` ${key}: z.preprocess((v) => typeof v === 'string' ? v.split(',') : v, ${inner}),`);
|
|
462
|
+
} else {
|
|
463
|
+
lines.push(` ${key}: ${renderType(param.type)},`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
lines.push(` })${suffix},`);
|
|
467
|
+
lines.push(` );`);
|
|
468
|
+
lines.push('');
|
|
469
|
+
}
|
|
470
|
+
} else {
|
|
471
|
+
// ContractTypeNode — use query-aware rendering for query params (coerces single string → array),
|
|
472
|
+
// otherwise use Input variant rendering; apply mode as a method call
|
|
473
|
+
const schema = isQuery ? renderQueryType(source.node, modelsWithInput) : renderInputType(source.node, modelsWithInput);
|
|
474
|
+
lines.push(` const ${varName} = await parseAndValidate(${ctxExpr}, (${schema}).${mode}());`);
|
|
475
|
+
lines.push('');
|
|
476
|
+
}
|
|
477
|
+
return lines;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ─── Type import resolution ────────────────────────────────────────────────
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Generate per-file type import statements.
|
|
484
|
+
* When modelOutPaths is available, groups types by their actual output file
|
|
485
|
+
* and computes correct relative paths. Falls back to the template-based
|
|
486
|
+
* single-import approach for types not found in the map.
|
|
487
|
+
*/
|
|
488
|
+
function generateTypeImports(types: string[], opFile: string, options: OpCodegenOptions): string[] {
|
|
489
|
+
const lines: string[] = [];
|
|
490
|
+
const { modelOutPaths, outPath } = options;
|
|
491
|
+
|
|
492
|
+
if (modelOutPaths && outPath) {
|
|
493
|
+
// Group types by their output file
|
|
494
|
+
const byFile = new Map<string, string[]>();
|
|
495
|
+
const unresolved: string[] = [];
|
|
496
|
+
|
|
497
|
+
for (const type of types) {
|
|
498
|
+
const typeOutPath = modelOutPaths.get(type);
|
|
499
|
+
if (typeOutPath) {
|
|
500
|
+
const group = byFile.get(typeOutPath) ?? [];
|
|
501
|
+
group.push(type);
|
|
502
|
+
byFile.set(typeOutPath, group);
|
|
503
|
+
} else {
|
|
504
|
+
unresolved.push(type);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Emit one import per source file with a relative path
|
|
509
|
+
const fromDir = dirname(outPath);
|
|
510
|
+
for (const [typeOutPath, names] of byFile) {
|
|
511
|
+
let rel = relative(fromDir, typeOutPath);
|
|
512
|
+
rel = rel.replace(/\.ts$/, '.js');
|
|
513
|
+
if (!rel.startsWith('.')) rel = './' + rel;
|
|
514
|
+
lines.push(`import { ${names.sort().join(', ')} } from '${rel}';`);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Fallback for types not in the map
|
|
518
|
+
for (const type of unresolved) {
|
|
519
|
+
const moduleName = pascalToDotCase(type);
|
|
520
|
+
lines.push(`import { ${type} } from './${moduleName}.js';`);
|
|
521
|
+
}
|
|
522
|
+
} else {
|
|
523
|
+
// No resolution context — fall back to template-based single import
|
|
524
|
+
const typeImport = deriveTypeImportPath(opFile, options.typeImportPathTemplate);
|
|
525
|
+
lines.push(`import { ${types.join(', ')} } from '${typeImport}';`);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return lines;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ─── Collection helpers ────────────────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
function collectTypes(root: OpRootNode, modelsWithInput?: Set<string>, modelsWithOutput?: Set<string>): string[] {
|
|
534
|
+
const types = new Set<string>();
|
|
535
|
+
for (const route of root.routes) {
|
|
536
|
+
collectParamSourceRefs(route.params, types);
|
|
537
|
+
collectParamSourceInputRefs(route.params, types, modelsWithInput);
|
|
538
|
+
for (const op of route.operations) {
|
|
539
|
+
if (op.request) {
|
|
540
|
+
for (const body of op.request.bodies) {
|
|
541
|
+
collectTypeNodeRefs(body.bodyType, types);
|
|
542
|
+
collectInputTypeNodeRefs(body.bodyType, types, modelsWithInput);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
for (const resp of op.responses) {
|
|
546
|
+
if (resp.bodyType) {
|
|
547
|
+
collectTypeNodeRefs(resp.bodyType, types);
|
|
548
|
+
collectOutputTypeNodeRefs(resp.bodyType, types, modelsWithOutput);
|
|
549
|
+
}
|
|
550
|
+
if (resp.headers) {
|
|
551
|
+
for (const h of resp.headers) {
|
|
552
|
+
collectTypeNodeRefs(h.type, types);
|
|
553
|
+
collectOutputTypeNodeRefs(h.type, types, modelsWithOutput);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
collectParamSourceRefs(op.query, types);
|
|
558
|
+
collectParamSourceInputRefs(op.query, types, modelsWithInput);
|
|
559
|
+
collectParamSourceRefs(op.headers, types);
|
|
560
|
+
collectParamSourceInputRefs(op.headers, types, modelsWithInput);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return [...types].sort();
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Collect Output variant refs for response-side ContractTypeNode types. */
|
|
567
|
+
function collectOutputTypeNodeRefs(type: ContractTypeNode, out: Set<string>, modelsWithOutput?: Set<string>): void {
|
|
568
|
+
if (!modelsWithOutput) return;
|
|
569
|
+
switch (type.kind) {
|
|
570
|
+
case 'ref':
|
|
571
|
+
if (modelsWithOutput.has(type.name)) out.add(`${type.name}Output`);
|
|
572
|
+
break;
|
|
573
|
+
case 'array':
|
|
574
|
+
collectOutputTypeNodeRefs(type.item, out, modelsWithOutput);
|
|
575
|
+
break;
|
|
576
|
+
case 'tuple':
|
|
577
|
+
type.items.forEach(t => collectOutputTypeNodeRefs(t, out, modelsWithOutput));
|
|
578
|
+
break;
|
|
579
|
+
case 'record':
|
|
580
|
+
collectOutputTypeNodeRefs(type.key, out, modelsWithOutput);
|
|
581
|
+
collectOutputTypeNodeRefs(type.value, out, modelsWithOutput);
|
|
582
|
+
break;
|
|
583
|
+
case 'union':
|
|
584
|
+
type.members.forEach(t => collectOutputTypeNodeRefs(t, out, modelsWithOutput));
|
|
585
|
+
break;
|
|
586
|
+
case 'discriminatedUnion':
|
|
587
|
+
type.members.forEach(t => collectOutputTypeNodeRefs(t, out, modelsWithOutput));
|
|
588
|
+
break;
|
|
589
|
+
case 'intersection':
|
|
590
|
+
type.members.forEach(t => collectOutputTypeNodeRefs(t, out, modelsWithOutput));
|
|
591
|
+
break;
|
|
592
|
+
case 'lazy':
|
|
593
|
+
collectOutputTypeNodeRefs(type.inner, out, modelsWithOutput);
|
|
594
|
+
break;
|
|
595
|
+
case 'inlineObject':
|
|
596
|
+
type.fields.forEach(f => collectOutputTypeNodeRefs(f.type, out, modelsWithOutput));
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function collectParamSourceRefs(source: ParamSource | undefined, out: Set<string>): void {
|
|
602
|
+
if (!source) return;
|
|
603
|
+
if (source.kind === 'ref') {
|
|
604
|
+
if (/^[A-Z]/.test(source.name)) out.add(source.name);
|
|
605
|
+
} else if (source.kind === 'params') {
|
|
606
|
+
for (const param of source.nodes) {
|
|
607
|
+
collectTypeNodeRefs(param.type, out);
|
|
608
|
+
}
|
|
609
|
+
} else {
|
|
610
|
+
collectTypeNodeRefs(source.node, out);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Collect Input variant refs for request-side ParamSource types. */
|
|
615
|
+
function collectParamSourceInputRefs(source: ParamSource | undefined, out: Set<string>, modelsWithInput?: Set<string>): void {
|
|
616
|
+
if (!source || !modelsWithInput) return;
|
|
617
|
+
if (source.kind === 'ref') {
|
|
618
|
+
if (modelsWithInput.has(source.name)) out.add(`${source.name}Input`);
|
|
619
|
+
} else if (source.kind === 'type') {
|
|
620
|
+
collectInputTypeNodeRefs(source.node, out, modelsWithInput);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Collect Input variant refs for request-side ContractTypeNode types. */
|
|
625
|
+
function collectInputTypeNodeRefs(type: ContractTypeNode, out: Set<string>, modelsWithInput?: Set<string>): void {
|
|
626
|
+
if (!modelsWithInput) return;
|
|
627
|
+
switch (type.kind) {
|
|
628
|
+
case 'ref':
|
|
629
|
+
if (modelsWithInput.has(type.name)) out.add(`${type.name}Input`);
|
|
630
|
+
break;
|
|
631
|
+
case 'array':
|
|
632
|
+
collectInputTypeNodeRefs(type.item, out, modelsWithInput);
|
|
633
|
+
break;
|
|
634
|
+
case 'tuple':
|
|
635
|
+
type.items.forEach(t => collectInputTypeNodeRefs(t, out, modelsWithInput));
|
|
636
|
+
break;
|
|
637
|
+
case 'record':
|
|
638
|
+
collectInputTypeNodeRefs(type.key, out, modelsWithInput);
|
|
639
|
+
collectInputTypeNodeRefs(type.value, out, modelsWithInput);
|
|
640
|
+
break;
|
|
641
|
+
case 'union':
|
|
642
|
+
type.members.forEach(t => collectInputTypeNodeRefs(t, out, modelsWithInput));
|
|
643
|
+
break;
|
|
644
|
+
case 'discriminatedUnion':
|
|
645
|
+
type.members.forEach(t => collectInputTypeNodeRefs(t, out, modelsWithInput));
|
|
646
|
+
break;
|
|
647
|
+
case 'intersection':
|
|
648
|
+
type.members.forEach(t => collectInputTypeNodeRefs(t, out, modelsWithInput));
|
|
649
|
+
break;
|
|
650
|
+
case 'lazy':
|
|
651
|
+
collectInputTypeNodeRefs(type.inner, out, modelsWithInput);
|
|
652
|
+
break;
|
|
653
|
+
case 'inlineObject':
|
|
654
|
+
type.fields.forEach(f => collectInputTypeNodeRefs(f.type, out, modelsWithInput));
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function collectTypeNodeRefs(type: ContractTypeNode, out: Set<string>): void {
|
|
660
|
+
switch (type.kind) {
|
|
661
|
+
case 'ref':
|
|
662
|
+
if (/^[A-Z]/.test(type.name)) out.add(type.name);
|
|
663
|
+
break;
|
|
664
|
+
case 'array':
|
|
665
|
+
collectTypeNodeRefs(type.item, out);
|
|
666
|
+
break;
|
|
667
|
+
case 'tuple':
|
|
668
|
+
type.items.forEach(t => collectTypeNodeRefs(t, out));
|
|
669
|
+
break;
|
|
670
|
+
case 'record':
|
|
671
|
+
collectTypeNodeRefs(type.key, out);
|
|
672
|
+
collectTypeNodeRefs(type.value, out);
|
|
673
|
+
break;
|
|
674
|
+
case 'union':
|
|
675
|
+
type.members.forEach(t => collectTypeNodeRefs(t, out));
|
|
676
|
+
break;
|
|
677
|
+
case 'discriminatedUnion':
|
|
678
|
+
type.members.forEach(t => collectTypeNodeRefs(t, out));
|
|
679
|
+
break;
|
|
680
|
+
case 'intersection':
|
|
681
|
+
type.members.forEach(t => collectTypeNodeRefs(t, out));
|
|
682
|
+
break;
|
|
683
|
+
case 'lazy':
|
|
684
|
+
collectTypeNodeRefs(type.inner, out);
|
|
685
|
+
break;
|
|
686
|
+
case 'inlineObject':
|
|
687
|
+
type.fields.forEach(f => collectTypeNodeRefs(f.type, out));
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function paramSourceNeedsDateTime(source: ParamSource | undefined): boolean {
|
|
693
|
+
if (!source) return false;
|
|
694
|
+
if (source.kind === 'ref') return false;
|
|
695
|
+
if (source.kind === 'params') return source.nodes.some(p => typeNeedsDateTime(p.type));
|
|
696
|
+
return typeNeedsDateTime(source.node);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function opNeedsDateTime(root: OpRootNode): boolean {
|
|
700
|
+
return root.routes.some(
|
|
701
|
+
route =>
|
|
702
|
+
paramSourceNeedsDateTime(route.params) ||
|
|
703
|
+
route.operations.some(
|
|
704
|
+
op =>
|
|
705
|
+
!!op.request?.bodies.some(b => typeNeedsDateTime(b.bodyType)) ||
|
|
706
|
+
op.responses.some(r => r.bodyType && typeNeedsDateTime(r.bodyType)) ||
|
|
707
|
+
paramSourceNeedsDateTime(op.query) ||
|
|
708
|
+
paramSourceNeedsDateTime(op.headers),
|
|
709
|
+
),
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function paramSourceNeedsScalar(source: ParamSource | undefined, name: string): boolean {
|
|
714
|
+
if (!source) return false;
|
|
715
|
+
if (source.kind === 'ref') return false;
|
|
716
|
+
if (source.kind === 'params') return source.nodes.some(p => typeNeedsScalar(p.type, name));
|
|
717
|
+
return typeNeedsScalar(source.node, name);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function opNeedsScalar(root: OpRootNode, name: string): boolean {
|
|
721
|
+
return root.routes.some(
|
|
722
|
+
route =>
|
|
723
|
+
paramSourceNeedsScalar(route.params, name) ||
|
|
724
|
+
route.operations.some(
|
|
725
|
+
op =>
|
|
726
|
+
!!op.request?.bodies.some(b => typeNeedsScalar(b.bodyType, name)) ||
|
|
727
|
+
op.responses.some(r => r.bodyType && typeNeedsScalar(r.bodyType, name)) ||
|
|
728
|
+
paramSourceNeedsScalar(op.query, name) ||
|
|
729
|
+
paramSourceNeedsScalar(op.headers, name),
|
|
730
|
+
),
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function collectServices(root: OpRootNode): string[] {
|
|
735
|
+
const services = new Set<string>();
|
|
736
|
+
const inferredService = `${deriveBaseName(root.file)}Service`;
|
|
737
|
+
|
|
738
|
+
for (const route of root.routes) {
|
|
739
|
+
for (const op of route.operations) {
|
|
740
|
+
if (op.service) {
|
|
741
|
+
services.add(op.service.split('.')[0] ?? op.service);
|
|
742
|
+
} else {
|
|
743
|
+
services.add(inferredService);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return [...services].sort();
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function hasParamSource(source?: ParamSource): boolean {
|
|
751
|
+
if (!source) return false;
|
|
752
|
+
if (source.kind === 'ref') return true;
|
|
753
|
+
if (source.kind === 'params') return source.nodes.length > 0;
|
|
754
|
+
return true; // type
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function routeNeedsValidation(root: OpRootNode): boolean {
|
|
758
|
+
return root.routes.some(
|
|
759
|
+
r => hasParamSource(r.params) || r.operations.some(op => !!op.request || hasParamSource(op.query) || hasParamSource(op.headers)),
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function fileNeedsSecurity(root: OpRootNode): boolean {
|
|
764
|
+
return root.routes.some(route => route.operations.some(op => resolveSecurity(route, op, root) !== SECURITY_NONE));
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function fileNeedsSignature(root: OpRootNode): boolean {
|
|
768
|
+
return root.routes.some(route => route.operations.some(op => !!op.signature));
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function isValidIdentifier(name: string): boolean {
|
|
772
|
+
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ─── Naming conventions ────────────────────────────────────────────────────
|
|
776
|
+
|
|
777
|
+
function deriveBaseName(file: string): string {
|
|
778
|
+
const base =
|
|
779
|
+
file
|
|
780
|
+
.split('/')
|
|
781
|
+
.pop()
|
|
782
|
+
?.replace(/\.(op|ck)$/, '') ?? 'Resource';
|
|
783
|
+
// ledger.categories -> LedgerCategories
|
|
784
|
+
return base
|
|
785
|
+
.split('.')
|
|
786
|
+
.map(s => s.charAt(0).toUpperCase() + s.slice(1))
|
|
787
|
+
.join('');
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function deriveRouterName(file: string): string {
|
|
791
|
+
return `${deriveBaseName(file)}Router`;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function deriveModulePath(serviceName: string, template?: string): string {
|
|
795
|
+
// LedgerService -> #modules/ledger/ledger.service.js
|
|
796
|
+
const base = serviceName.replace(/Service$/, '');
|
|
797
|
+
const kebab = base.replace(/([A-Z])/g, m => `-${m.toLowerCase()}`).replace(/^-/, '');
|
|
798
|
+
if (template) {
|
|
799
|
+
return template.replace(/\{name\}/g, base).replace(/\{kebab\}/g, kebab);
|
|
800
|
+
}
|
|
801
|
+
return `#modules/${kebab}/${kebab}.service.js`;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function deriveTypeImportPath(file: string, template?: string): string {
|
|
805
|
+
const base =
|
|
806
|
+
file
|
|
807
|
+
.split('/')
|
|
808
|
+
.pop()
|
|
809
|
+
?.replace(/\.(op|ck)$/, '') ?? 'resource';
|
|
810
|
+
const module = base.split('.')[0] ?? base;
|
|
811
|
+
if (template) {
|
|
812
|
+
return template.replace(/\{module\}/g, module).replace(/\{base\}/g, base);
|
|
813
|
+
}
|
|
814
|
+
return `#modules/${module}/types/index.js`;
|
|
815
|
+
}
|