@dudousxd/nestjs-codegen 0.14.2 → 0.16.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/CHANGELOG.md CHANGED
@@ -1,5 +1,54 @@
1
1
  # @dudousxd/nestjs-codegen
2
2
 
3
+ ## 0.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Discover routes on mixin (factory-produced) controllers.
8
+
9
+ A `@Controller` class whose heritage clause is a factory call now contributes the
10
+ decorated methods of the class that factory returns:
11
+
12
+ ```ts
13
+ @Controller("base/util/search")
14
+ export class SearchUtilController extends createTableController(Util, {
15
+ dto: UtilDTO,
16
+ }) {}
17
+ ```
18
+
19
+ NestJS already routes inherited methods at runtime via the prototype chain; the
20
+ static discovery pass now follows the same link. Each such route carries a new
21
+ `controllerRef.mixin` binding recording the factory and its call-site class
22
+ arguments — the decorator arguments _inside_ a factory reference its own
23
+ parameters, so the concrete entity is only knowable from the call site.
24
+
25
+ That binding drives two things:
26
+
27
+ - **`filterFields`** are derived from the call-site entity, so
28
+ `@ApplyFilter(GeneratedFilter)` works even though the filter class is declared
29
+ inside the factory and its `@Filterable({ entity })` names a parameter. This is
30
+ also what flips these routes from mutation to query in the emitted client.
31
+ - **Response types** are instantiated against the derived class, so
32
+ `Paginated<D>` resolves to the concrete `D`. This needs the type checker, which
33
+ needs lib files — the discovery Project sets `skipLoadingLibFiles` for
34
+ cold-start speed, so mixin response types resolve through a second Project
35
+ built lazily on the first mixin controller.
36
+
37
+ **Behaviour change:** `joinPaths` now always emits a leading slash.
38
+ `@Controller('items')` + `@Post(':id')` previously produced `items/:id` while
39
+ `@Controller('items')` alone produced `/items` — the prefix+suffix branch was the
40
+ only one that did not add it. The client's `buildUrl()` normalises before
41
+ requesting, so no URL was ever broken by this, but the raw value reaches the
42
+ emitted `ROUTES` map and the OpenAPI export, where a path without a leading slash
43
+ is invalid.
44
+
45
+ ## 0.15.0
46
+
47
+ ### Minor Changes
48
+
49
+ - 20db5c0: Emit `filterFields` as a runtime `as const` array on each filter leaf, alongside the existing type-level union, plus an `isFilterField` type guard exported from the generated `api.ts`. Previously the filterable field set existed only as a type, so a field name arriving as a plain `string` from runtime state (a saved view, a user-picked column) could not be passed to `filterQuery().where()` without a cast. Now `api.route.leaf().filterFields` is a `readonly [...] as const` value and `isFilterField(leaf.filterFields, value)` narrows an arbitrary string to the field union, so dynamic field names validate at runtime instead of being asserted with `as`. The runtime array is generated from the same discovered field list as the type-level union (single source in the emitter), so the value can never drift from the type. Purely additive — the guard is emitted only when a route carries filter fields, and leaves without a filter gain no new member.
50
+ - 9b5298b: Add a `@QueryList()` param decorator and a `toStringList` normalizer to the `/nest` subpath for receiving array query params safely. Express (and Nest's default query parser) returns a bare `string` for a single-value query param (`?ids=a`) and a `string[]` only for two or more (`?ids=a&ids=b`), so `ParseArrayPipe` 400s the common single-select case. `@QueryList('ids')` normalizes `string | string[] | comma-joined string | undefined` into a clean `string[]` (`['a']`, `['a','b']`, `[]`), and `toStringList` is exported for the equivalent `class-transformer` `@Transform` on a DTO field. Pairs with the client's `arrayFormat` option: once the client sends `arrayFormat: 'repeat'`, the comma-split becomes a no-op fallback that still covers hand-rolled and `curl` callers. Documented under a new "Receiving array query params" docs page.
51
+
3
52
  ## 0.14.2
4
53
 
5
54
  ### Patch Changes
package/dist/cli/main.cjs CHANGED
@@ -775,6 +775,9 @@ function buildErrorType(c) {
775
775
  }
776
776
  return c.contractSource.error ?? "unknown";
777
777
  }
778
+ function filterFieldLiterals(fields) {
779
+ return fields?.length ? fields.map((f) => JSON.stringify(f)) : [];
780
+ }
778
781
  function emitRouterTypeBlock(tree, indent, outDir, serialization) {
779
782
  const pad = " ".repeat(indent);
780
783
  const lines = [];
@@ -801,7 +804,8 @@ function emitRouterTypeBlock(tree, indent, outDir, serialization) {
801
804
  const params = buildParamsType(c.params);
802
805
  const safeMethod = JSON.stringify(method);
803
806
  const safeUrl = JSON.stringify(c.path);
804
- const filterFields = c.contractSource.filterFields?.length ? c.contractSource.filterFields.map((f) => JSON.stringify(f)).join(" | ") : "never";
807
+ const filterLiterals = filterFieldLiterals(c.contractSource.filterFields);
808
+ const filterFields = filterLiterals.length ? filterLiterals.join(" | ") : "never";
805
809
  const stream = c.contractSource.stream ? "true" : "false";
806
810
  const binary = c.contractSource.binaryResponse ? "true" : "false";
807
811
  lines.push(
@@ -841,6 +845,7 @@ function buildRequestModel(c) {
841
845
  const TA = buildRouterTypeAccess(c.name);
842
846
  const withParams = hasPathParams(c.params);
843
847
  const { isGet, isQuery, hasBody, hasQuery } = requestShape(c.route);
848
+ const filterLiterals = filterFieldLiterals(c.contractSource.filterFields);
844
849
  const fields = [];
845
850
  if (withParams) fields.push(`params: ${TA}['params']`);
846
851
  if (hasQuery) fields.push(`query?: ${TA}['query']`);
@@ -870,7 +875,12 @@ function buildRequestModel(c) {
870
875
  // (`[name]` rather than `[name, undefined]`) so the bare `.queryKey()` is a
871
876
  // clean prefix that partial-matches every parametrized variant — making it
872
877
  // directly usable for `invalidateQueries`.
873
- queryKeyExpr: `(input === undefined ? [${flat}] as const : [${flat}, input] as const)`
878
+ queryKeyExpr: `(input === undefined ? [${flat}] as const : [${flat}, input] as const)`,
879
+ // Runtime counterpart to the type-level `filterFields` union: the same
880
+ // discovered field list, emitted as a literal `[...] as const` so apps can
881
+ // validate a dynamic/user-supplied field string with `isFilterField(...)`
882
+ // instead of casting. Omitted for routes with no filter.
883
+ ...filterLiterals.length ? { filterFieldsExpr: `[${filterLiterals.join(", ")}] as const` } : {}
874
884
  };
875
885
  }
876
886
  function renderFetcherRequest(req, binaryResponse) {
@@ -905,9 +915,24 @@ function emitReqHelper() {
905
915
  ""
906
916
  ];
907
917
  }
918
+ function emitFilterFieldGuard() {
919
+ return [
920
+ "/** Runtime guard: narrows `value` to one of the leaf's `filterFields` (a `readonly K[] as const`), so a dynamic field string can be passed to `.where()` without a cast. */",
921
+ "export function isFilterField<const K extends string>(",
922
+ " fields: readonly K[],",
923
+ " value: string,",
924
+ "): value is K {",
925
+ " return (fields as readonly string[]).includes(value);",
926
+ "}",
927
+ ""
928
+ ];
929
+ }
908
930
  function renderLeaf(pad, objKey, req, requestExpr, members, streamExpr) {
909
931
  const lines = [`${pad}${objKey}: (input?: ${req.inputType}) => ({`];
910
932
  lines.push(`${pad} ...__req<${req.responseType}>(() => ${requestExpr}),`);
933
+ if (req.filterFieldsExpr) {
934
+ lines.push(`${pad} filterFields: ${req.filterFieldsExpr},`);
935
+ }
911
936
  if (streamExpr) {
912
937
  lines.push(`${pad} stream: () => ${streamExpr},`);
913
938
  }
@@ -1179,6 +1204,9 @@ function buildApiFile(routes, outDir, opts = {}) {
1179
1204
  lines.push("};");
1180
1205
  lines.push("");
1181
1206
  lines.push(...emitReqHelper());
1207
+ if (contracted.some((r) => r.contract?.contractSource.filterFields?.length)) {
1208
+ lines.push(...emitFilterFieldGuard());
1209
+ }
1182
1210
  lines.push("export function createApi(fetcher: Fetcher) {");
1183
1211
  lines.push(" return {");
1184
1212
  lines.push(
@@ -2414,7 +2442,7 @@ var import_chokidar = __toESM(require("chokidar"), 1);
2414
2442
  // src/discovery/contracts-fast.ts
2415
2443
  var import_node_path15 = require("path");
2416
2444
  var import_fast_glob3 = __toESM(require("fast-glob"), 1);
2417
- var import_ts_morph9 = require("ts-morph");
2445
+ var import_ts_morph10 = require("ts-morph");
2418
2446
 
2419
2447
  // src/discovery/dto-type-resolver.ts
2420
2448
  var import_ts_morph7 = require("ts-morph");
@@ -3393,6 +3421,12 @@ function toFilterFieldType(name, r) {
3393
3421
  }
3394
3422
 
3395
3423
  // src/discovery/filter-for.ts
3424
+ function resolveMixinEntityClass(mixin, project) {
3425
+ const entityArg = mixin?.classArgs[0];
3426
+ if (!entityArg) return void 0;
3427
+ const file = project.getSourceFile(entityArg.filePath) ?? project.addSourceFileAtPathIfExists(entityArg.filePath);
3428
+ return file?.getClass(entityArg.name);
3429
+ }
3396
3430
  function classifyFilterForHint(typeInit) {
3397
3431
  if (import_ts_morph6.Node.isStringLiteral(typeInit)) {
3398
3432
  switch (typeInit.getLiteralValue()) {
@@ -3466,7 +3500,9 @@ function extractFilterForHints(classDecl, project) {
3466
3500
  }
3467
3501
  return hints;
3468
3502
  }
3469
- function extractApplyFilterInfo(method, sourceFile, project) {
3503
+ function extractApplyFilterInfo(method, sourceFile, project, mixin) {
3504
+ const declFile = method.getSourceFile();
3505
+ const mixinEntity = resolveMixinEntityClass(mixin, project);
3470
3506
  for (const param of method.getParameters()) {
3471
3507
  const filterDecorator = param.getDecorators().find((d) => d.getName() === "ApplyFilter");
3472
3508
  if (!filterDecorator) continue;
@@ -3486,12 +3522,12 @@ function extractApplyFilterInfo(method, sourceFile, project) {
3486
3522
  }
3487
3523
  }
3488
3524
  const filterClassName = filterClassArg.getText();
3489
- const resolved = findType(filterClassName, sourceFile, project);
3490
- if (resolved && resolved.kind === "class") {
3491
- const classDecl = resolved.decl;
3525
+ const resolved = findType(filterClassName, declFile, project);
3526
+ const classDecl = resolved?.kind === "class" ? resolved.decl : resolveLocalClassDeclaration(filterClassArg);
3527
+ if (classDecl) {
3492
3528
  let fieldTypes = extractClassPropertyTypes(classDecl, project);
3493
3529
  if (fieldTypes.length === 0) {
3494
- fieldTypes = extractFilterableEntityFields(classDecl, project);
3530
+ fieldTypes = extractFilterableEntityFields(classDecl, project, mixinEntity);
3495
3531
  }
3496
3532
  const filterForHints = extractFilterForHints(classDecl, project);
3497
3533
  if (filterForHints.size > 0) {
@@ -3576,22 +3612,29 @@ function extractClassPropertyTypes(classDecl, project) {
3576
3612
  }
3577
3613
  return fields;
3578
3614
  }
3579
- function extractFilterableEntityFields(filterClass, project) {
3615
+ function resolveLocalClassDeclaration(identifier) {
3616
+ if (!import_ts_morph6.Node.isIdentifier(identifier)) return void 0;
3617
+ return identifier.getDefinitions().map((d) => d.getDeclarationNode()).find((n) => n !== void 0 && import_ts_morph6.Node.isClassDeclaration(n));
3618
+ }
3619
+ function resolveDeclaredEntity(optionsArg, filterClass, project) {
3620
+ if (!import_ts_morph6.Node.isObjectLiteralExpression(optionsArg)) return void 0;
3621
+ const entityProp = optionsArg.getProperty("entity");
3622
+ if (!entityProp || !import_ts_morph6.Node.isPropertyAssignment(entityProp)) return void 0;
3623
+ const entityInit = entityProp.getInitializer();
3624
+ if (!entityInit || !import_ts_morph6.Node.isIdentifier(entityInit)) return void 0;
3625
+ const resolved = findType(entityInit.getText(), filterClass.getSourceFile(), project);
3626
+ if (!resolved || resolved.kind !== "class") return void 0;
3627
+ return resolved.decl;
3628
+ }
3629
+ function extractFilterableEntityFields(filterClass, project, entityOverride) {
3580
3630
  const filterableDecorator = filterClass.getDecorators().find((d) => d.getName() === "Filterable");
3581
3631
  if (!filterableDecorator) return [];
3582
3632
  const args = filterableDecorator.getArguments();
3583
3633
  if (args.length === 0) return [];
3584
3634
  const optionsArg = args[0];
3585
3635
  if (!import_ts_morph6.Node.isObjectLiteralExpression(optionsArg)) return [];
3586
- const entityProp = optionsArg.getProperty("entity");
3587
- if (!entityProp || !import_ts_morph6.Node.isPropertyAssignment(entityProp)) return [];
3588
- const entityInit = entityProp.getInitializer();
3589
- if (!entityInit || !import_ts_morph6.Node.isIdentifier(entityInit)) return [];
3590
- const entityName = entityInit.getText();
3591
- const filterSourceFile = filterClass.getSourceFile();
3592
- const resolvedEntity = findType(entityName, filterSourceFile, project);
3593
- if (!resolvedEntity || resolvedEntity.kind !== "class") return [];
3594
- const entityDecl = resolvedEntity.decl;
3636
+ const entityDecl = entityOverride ?? resolveDeclaredEntity(optionsArg, filterClass, project);
3637
+ if (!entityDecl) return [];
3595
3638
  const fields = collectEntityFields(
3596
3639
  entityDecl,
3597
3640
  entityDecl.getSourceFile(),
@@ -4016,9 +4059,9 @@ function unwrapNamedContainer(node, names) {
4016
4059
  }
4017
4060
  return node;
4018
4061
  }
4019
- function extractDtoContract(method, sourceFile, project) {
4062
+ function extractDtoContract(method, sourceFile, project, mixin) {
4020
4063
  let body = extractBodyType(method, sourceFile, project);
4021
- const filterInfo = extractApplyFilterInfo(method, sourceFile, project);
4064
+ const filterInfo = extractApplyFilterInfo(method, sourceFile, project, mixin);
4022
4065
  const query = extractQueryType(method, sourceFile, project);
4023
4066
  const uploads = extractUploadedFiles(method);
4024
4067
  const multipartBody = uploads.fields ? `{ ${uploads.fields} }` : null;
@@ -4128,12 +4171,94 @@ function resolveParamClass(method, decoratorName, sourceFile, project) {
4128
4171
  return null;
4129
4172
  }
4130
4173
 
4131
- // src/discovery/zod-ast-to-ts.ts
4174
+ // src/discovery/heritage.ts
4132
4175
  var import_ts_morph8 = require("ts-morph");
4176
+ function unwrapExpression(node) {
4177
+ let current = node;
4178
+ while (import_ts_morph8.Node.isAsExpression(current) || import_ts_morph8.Node.isSatisfiesExpression(current) || import_ts_morph8.Node.isParenthesizedExpression(current) || import_ts_morph8.Node.isTypeAssertion(current)) {
4179
+ current = current.getExpression();
4180
+ }
4181
+ return current;
4182
+ }
4183
+ function resolveReturnedClass(factoryDecl) {
4184
+ const body = factoryDecl.getBody();
4185
+ if (!body) return void 0;
4186
+ const returns = body.getDescendants().filter((n) => import_ts_morph8.Node.isReturnStatement(n));
4187
+ for (const ret of returns) {
4188
+ const expr = ret.getExpression();
4189
+ if (!expr) continue;
4190
+ const inner = unwrapExpression(expr);
4191
+ if (import_ts_morph8.Node.isClassExpression(inner)) {
4192
+ return inner;
4193
+ }
4194
+ if (import_ts_morph8.Node.isIdentifier(inner)) {
4195
+ const name = inner.getText();
4196
+ const decl = body.getDescendants().find((n) => import_ts_morph8.Node.isClassDeclaration(n) && n.getName() === name);
4197
+ if (decl) return decl;
4198
+ }
4199
+ }
4200
+ return void 0;
4201
+ }
4202
+ function resolveInheritedMethods(cls) {
4203
+ const expr = cls.getExtends()?.getExpression();
4204
+ if (!expr || !import_ts_morph8.Node.isCallExpression(expr)) return void 0;
4205
+ const callee = expr.getExpression();
4206
+ if (!import_ts_morph8.Node.isIdentifier(callee)) return void 0;
4207
+ const factoryDecl = callee.getDefinitions().map((d) => d.getDeclarationNode()).find((n) => import_ts_morph8.Node.isFunctionDeclaration(n));
4208
+ if (!factoryDecl) return void 0;
4209
+ const returnedClass = resolveReturnedClass(factoryDecl);
4210
+ if (!returnedClass) return void 0;
4211
+ const classArgs = [];
4212
+ for (const arg of expr.getArguments()) {
4213
+ if (!import_ts_morph8.Node.isIdentifier(arg)) continue;
4214
+ const decl = arg.getDefinitions().map((d) => d.getDeclarationNode()).find((n) => import_ts_morph8.Node.isClassDeclaration(n));
4215
+ if (decl) {
4216
+ classArgs.push({
4217
+ name: decl.getName() ?? arg.getText(),
4218
+ filePath: decl.getSourceFile().getFilePath()
4219
+ });
4220
+ }
4221
+ }
4222
+ return {
4223
+ methods: returnedClass.getMethods(),
4224
+ factoryName: callee.getText(),
4225
+ factoryFilePath: factoryDecl.getSourceFile().getFilePath(),
4226
+ classArgs
4227
+ };
4228
+ }
4229
+ var mixinTypeProject;
4230
+ function getMixinTypeProject() {
4231
+ mixinTypeProject ??= new import_ts_morph8.Project({
4232
+ skipAddingFilesFromTsConfig: true,
4233
+ compilerOptions: { strict: true }
4234
+ });
4235
+ return mixinTypeProject;
4236
+ }
4237
+ function clearMixinTypeProject() {
4238
+ mixinTypeProject = void 0;
4239
+ }
4240
+ function resolveInstantiatedReturnType(cls, methodName) {
4241
+ const filePath = cls.getSourceFile().getFilePath();
4242
+ const className = cls.getName();
4243
+ if (!className) return void 0;
4244
+ const project = getMixinTypeProject();
4245
+ const sourceFile = project.getSourceFile(filePath) ?? project.addSourceFileAtPathIfExists(filePath);
4246
+ const typedCls = sourceFile?.getClass(className);
4247
+ if (!typedCls) return void 0;
4248
+ const prop = typedCls.getType().getProperty(methodName);
4249
+ if (!prop) return void 0;
4250
+ const returnType = prop.getTypeAtLocation(typedCls).getCallSignatures()[0]?.getReturnType();
4251
+ if (!returnType) return void 0;
4252
+ const unwrapped = returnType.getSymbol()?.getName() === "Promise" ? returnType.getTypeArguments()[0] ?? returnType : returnType;
4253
+ return unwrapped.getText(typedCls);
4254
+ }
4255
+
4256
+ // src/discovery/zod-ast-to-ts.ts
4257
+ var import_ts_morph9 = require("ts-morph");
4133
4258
  function zodAstToTs(node) {
4134
- if (!import_ts_morph8.Node.isCallExpression(node)) return "unknown";
4259
+ if (!import_ts_morph9.Node.isCallExpression(node)) return "unknown";
4135
4260
  const expr = node.getExpression();
4136
- if (import_ts_morph8.Node.isPropertyAccessExpression(expr)) {
4261
+ if (import_ts_morph9.Node.isPropertyAccessExpression(expr)) {
4137
4262
  const methodName = expr.getName();
4138
4263
  const receiver = expr.getExpression();
4139
4264
  if (methodName === "optional") {
@@ -4157,17 +4282,17 @@ function zodAstToTs(node) {
4157
4282
  case "literal": {
4158
4283
  const lit = args[0];
4159
4284
  if (!lit) return "unknown";
4160
- if (import_ts_morph8.Node.isStringLiteral(lit)) return JSON.stringify(lit.getLiteralValue());
4161
- if (import_ts_morph8.Node.isNumericLiteral(lit)) return lit.getLiteralValue().toString();
4162
- if (lit.getKind() === import_ts_morph8.SyntaxKind.TrueKeyword) return "true";
4163
- if (lit.getKind() === import_ts_morph8.SyntaxKind.FalseKeyword) return "false";
4285
+ if (import_ts_morph9.Node.isStringLiteral(lit)) return JSON.stringify(lit.getLiteralValue());
4286
+ if (import_ts_morph9.Node.isNumericLiteral(lit)) return lit.getLiteralValue().toString();
4287
+ if (lit.getKind() === import_ts_morph9.SyntaxKind.TrueKeyword) return "true";
4288
+ if (lit.getKind() === import_ts_morph9.SyntaxKind.FalseKeyword) return "false";
4164
4289
  return "unknown";
4165
4290
  }
4166
4291
  case "enum": {
4167
4292
  const arrArg = args[0];
4168
- if (!arrArg || !import_ts_morph8.Node.isArrayLiteralExpression(arrArg)) return "unknown";
4293
+ if (!arrArg || !import_ts_morph9.Node.isArrayLiteralExpression(arrArg)) return "unknown";
4169
4294
  const members = arrArg.getElements().map(
4170
- (el) => import_ts_morph8.Node.isStringLiteral(el) ? JSON.stringify(el.getLiteralValue()) : "unknown"
4295
+ (el) => import_ts_morph9.Node.isStringLiteral(el) ? JSON.stringify(el.getLiteralValue()) : "unknown"
4171
4296
  );
4172
4297
  return members.join(" | ");
4173
4298
  }
@@ -4178,10 +4303,10 @@ function zodAstToTs(node) {
4178
4303
  }
4179
4304
  case "object": {
4180
4305
  const objArg = args[0];
4181
- if (!objArg || !import_ts_morph8.Node.isObjectLiteralExpression(objArg)) return "unknown";
4306
+ if (!objArg || !import_ts_morph9.Node.isObjectLiteralExpression(objArg)) return "unknown";
4182
4307
  const lines = [];
4183
4308
  for (const prop of objArg.getProperties()) {
4184
- if (!import_ts_morph8.Node.isPropertyAssignment(prop)) continue;
4309
+ if (!import_ts_morph9.Node.isPropertyAssignment(prop)) continue;
4185
4310
  const key = prop.getName();
4186
4311
  const valNode = prop.getInitializer();
4187
4312
  if (!valNode) continue;
@@ -4193,7 +4318,7 @@ function zodAstToTs(node) {
4193
4318
  }
4194
4319
  case "union": {
4195
4320
  const arrArg = args[0];
4196
- if (!arrArg || !import_ts_morph8.Node.isArrayLiteralExpression(arrArg)) return "unknown";
4321
+ if (!arrArg || !import_ts_morph9.Node.isArrayLiteralExpression(arrArg)) return "unknown";
4197
4322
  return arrArg.getElements().map(zodAstToTs).join(" | ");
4198
4323
  }
4199
4324
  case "record": {
@@ -4203,7 +4328,7 @@ function zodAstToTs(node) {
4203
4328
  }
4204
4329
  case "tuple": {
4205
4330
  const arrArg = args[0];
4206
- if (!arrArg || !import_ts_morph8.Node.isArrayLiteralExpression(arrArg)) return "unknown";
4331
+ if (!arrArg || !import_ts_morph9.Node.isArrayLiteralExpression(arrArg)) return "unknown";
4207
4332
  return `[${arrArg.getElements().map(zodAstToTs).join(", ")}]`;
4208
4333
  }
4209
4334
  default:
@@ -4213,18 +4338,18 @@ function zodAstToTs(node) {
4213
4338
  return "unknown";
4214
4339
  }
4215
4340
  function isOptionalChain(node) {
4216
- if (!import_ts_morph8.Node.isCallExpression(node)) return false;
4341
+ if (!import_ts_morph9.Node.isCallExpression(node)) return false;
4217
4342
  const expr = node.getExpression();
4218
- return import_ts_morph8.Node.isPropertyAccessExpression(expr) && expr.getName() === "optional";
4343
+ return import_ts_morph9.Node.isPropertyAccessExpression(expr) && expr.getName() === "optional";
4219
4344
  }
4220
4345
  function parseDefineContractCall(callExpr) {
4221
- if (!import_ts_morph8.Node.isCallExpression(callExpr)) return null;
4346
+ if (!import_ts_morph9.Node.isCallExpression(callExpr)) return null;
4222
4347
  const callee = callExpr.getExpression();
4223
- const calleeName = import_ts_morph8.Node.isIdentifier(callee) ? callee.getText() : import_ts_morph8.Node.isPropertyAccessExpression(callee) ? callee.getName() : "";
4348
+ const calleeName = import_ts_morph9.Node.isIdentifier(callee) ? callee.getText() : import_ts_morph9.Node.isPropertyAccessExpression(callee) ? callee.getName() : "";
4224
4349
  if (calleeName !== "defineContract") return null;
4225
4350
  const args = callExpr.getArguments();
4226
4351
  const optsArg = args[0];
4227
- if (!optsArg || !import_ts_morph8.Node.isObjectLiteralExpression(optsArg)) return null;
4352
+ if (!optsArg || !import_ts_morph9.Node.isObjectLiteralExpression(optsArg)) return null;
4228
4353
  let query = null;
4229
4354
  let body = null;
4230
4355
  let response = "unknown";
@@ -4232,7 +4357,7 @@ function parseDefineContractCall(callExpr) {
4232
4357
  let bodyZodText = null;
4233
4358
  let queryZodText = null;
4234
4359
  for (const prop of optsArg.getProperties()) {
4235
- if (!import_ts_morph8.Node.isPropertyAssignment(prop)) continue;
4360
+ if (!import_ts_morph9.Node.isPropertyAssignment(prop)) continue;
4236
4361
  const propName = prop.getName();
4237
4362
  const val = prop.getInitializer();
4238
4363
  if (!val) continue;
@@ -4261,6 +4386,7 @@ async function discoverContractsFast(opts) {
4261
4386
  project.addSourceFileAtPath(f);
4262
4387
  }
4263
4388
  bindDiscoveryContext(project, cwd, tsconfigPath);
4389
+ clearMixinTypeProject();
4264
4390
  return extractAllRoutes(project);
4265
4391
  }
4266
4392
  function resolveTsconfigPath(cwd, tsconfig) {
@@ -4268,14 +4394,14 @@ function resolveTsconfigPath(cwd, tsconfig) {
4268
4394
  }
4269
4395
  function createDiscoveryProject(tsconfigPath) {
4270
4396
  try {
4271
- return new import_ts_morph9.Project({
4397
+ return new import_ts_morph10.Project({
4272
4398
  tsConfigFilePath: tsconfigPath,
4273
4399
  skipAddingFilesFromTsConfig: true,
4274
4400
  skipLoadingLibFiles: true,
4275
4401
  skipFileDependencyResolution: true
4276
4402
  });
4277
4403
  } catch {
4278
- return new import_ts_morph9.Project({
4404
+ return new import_ts_morph10.Project({
4279
4405
  skipAddingFilesFromTsConfig: true,
4280
4406
  skipLoadingLibFiles: true,
4281
4407
  skipFileDependencyResolution: true,
@@ -4382,15 +4508,16 @@ var PersistentDiscovery = class _PersistentDiscovery {
4382
4508
  runExtraction() {
4383
4509
  clearTypeResolutionCaches(this.project);
4384
4510
  clearEnumCache(this.project);
4511
+ clearMixinTypeProject();
4385
4512
  return extractRoutesFrom(this.project, this.controllerPaths);
4386
4513
  }
4387
4514
  };
4388
4515
  function decoratorStringArg(decoratorExpr) {
4389
4516
  if (!decoratorExpr) return void 0;
4390
- if (import_ts_morph9.Node.isStringLiteral(decoratorExpr)) return decoratorExpr.getLiteralValue();
4391
- if (import_ts_morph9.Node.isArrayLiteralExpression(decoratorExpr)) {
4517
+ if (import_ts_morph10.Node.isStringLiteral(decoratorExpr)) return decoratorExpr.getLiteralValue();
4518
+ if (import_ts_morph10.Node.isArrayLiteralExpression(decoratorExpr)) {
4392
4519
  const first = decoratorExpr.getElements()[0];
4393
- if (first && import_ts_morph9.Node.isStringLiteral(first)) return first.getLiteralValue();
4520
+ if (first && import_ts_morph10.Node.isStringLiteral(first)) return first.getLiteralValue();
4394
4521
  }
4395
4522
  return void 0;
4396
4523
  }
@@ -4412,7 +4539,8 @@ function joinPaths(prefix, suffix) {
4412
4539
  if (!prefix && !suffix) return "/";
4413
4540
  if (!prefix) return suffix.startsWith("/") ? suffix : `/${suffix}`;
4414
4541
  if (!suffix) return prefix.startsWith("/") ? prefix : `/${prefix}`;
4415
- const p = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
4542
+ const withLeadingSlash = prefix.startsWith("/") ? prefix : `/${prefix}`;
4543
+ const p = withLeadingSlash.endsWith("/") ? withLeadingSlash.slice(0, -1) : withLeadingSlash;
4416
4544
  const s = suffix.startsWith("/") ? suffix : `/${suffix}`;
4417
4545
  const combined = p + s;
4418
4546
  return combined === "" ? "/" : combined;
@@ -4466,7 +4594,8 @@ function buildRoute(args) {
4466
4594
  methodAs,
4467
4595
  sourceFile,
4468
4596
  seenNames,
4469
- contractSource
4597
+ contractSource,
4598
+ mixin
4470
4599
  } = args;
4471
4600
  const routeName = resolveRouteName(className, methodName, classAs, methodAs);
4472
4601
  const qualifiedRef = `${className}.${methodName}`;
@@ -4482,7 +4611,12 @@ function buildRoute(args) {
4482
4611
  path: combinedPath,
4483
4612
  name: routeName,
4484
4613
  params: extractParams(combinedPath),
4485
- controllerRef: { className, methodName, filePath: sourceFile.getFilePath() },
4614
+ controllerRef: {
4615
+ className,
4616
+ methodName,
4617
+ filePath: sourceFile.getFilePath(),
4618
+ ...mixin ? { mixin } : {}
4619
+ },
4486
4620
  contract: { contractSource }
4487
4621
  };
4488
4622
  }
@@ -4496,16 +4630,17 @@ function extractContractRoute(args) {
4496
4630
  className,
4497
4631
  sourceFile,
4498
4632
  project,
4499
- seenNames
4633
+ seenNames,
4634
+ mixin
4500
4635
  } = args;
4501
4636
  const firstDecoratorArg = applyContractDecorator.getArguments()[0];
4502
4637
  if (!firstDecoratorArg) return null;
4503
4638
  let contractDef = null;
4504
4639
  let bodyZodRef = null;
4505
4640
  let queryZodRef = null;
4506
- if (import_ts_morph9.Node.isCallExpression(firstDecoratorArg)) {
4641
+ if (import_ts_morph10.Node.isCallExpression(firstDecoratorArg)) {
4507
4642
  contractDef = parseDefineContractCall(firstDecoratorArg);
4508
- } else if (import_ts_morph9.Node.isIdentifier(firstDecoratorArg)) {
4643
+ } else if (import_ts_morph10.Node.isIdentifier(firstDecoratorArg)) {
4509
4644
  const identName = firstDecoratorArg.getText();
4510
4645
  const resolvedVar = resolveImportedVariable(identName, sourceFile, project);
4511
4646
  if (!resolvedVar) {
@@ -4548,6 +4683,7 @@ function extractContractRoute(args) {
4548
4683
  methodAs,
4549
4684
  sourceFile,
4550
4685
  seenNames,
4686
+ mixin,
4551
4687
  contractSource: {
4552
4688
  query: contractDef.query,
4553
4689
  body: contractDef.body,
@@ -4564,13 +4700,14 @@ function extractContractRoute(args) {
4564
4700
  });
4565
4701
  }
4566
4702
  function extractDtoRoute(args) {
4567
- const { cls, method, verb, prefix, className, sourceFile, project, seenNames } = args;
4703
+ const { cls, method, verb, prefix, className, sourceFile, project, seenNames, mixin } = args;
4568
4704
  if (!verb) return null;
4569
4705
  const combined = joinPaths(prefix, verb.handlerPath);
4570
4706
  const methodName = method.getName();
4571
4707
  const classAs = readAsDecorator(cls, `class ${className}`);
4572
4708
  const methodAs = readAsDecorator(method, `${className}.${methodName}`);
4573
- const dtoContract = extractDtoContract(method, sourceFile, project);
4709
+ const dtoContract = extractDtoContract(method, sourceFile, project, mixin);
4710
+ const mixinResponse = mixin ? resolveInstantiatedReturnType(cls, methodName) : void 0;
4574
4711
  return buildRoute({
4575
4712
  className,
4576
4713
  methodName,
@@ -4580,10 +4717,11 @@ function extractDtoRoute(args) {
4580
4717
  methodAs,
4581
4718
  sourceFile,
4582
4719
  seenNames,
4720
+ mixin,
4583
4721
  contractSource: {
4584
4722
  query: dtoContract?.query ?? null,
4585
4723
  body: dtoContract?.body ?? null,
4586
- response: dtoContract?.response ?? "unknown",
4724
+ response: mixinResponse ?? dtoContract?.response ?? "unknown",
4587
4725
  error: dtoContract?.error ?? null,
4588
4726
  queryRef: dtoContract?.queryRef ?? null,
4589
4727
  bodyRef: dtoContract?.bodyRef ?? null,
@@ -4612,7 +4750,17 @@ function extractFromSourceFile(sourceFile, project) {
4612
4750
  const firstArg2 = controllerDecorator.getArguments()[0];
4613
4751
  const prefix = decoratorStringArg(firstArg2) ?? "";
4614
4752
  const className = cls.getName() ?? "Unknown";
4615
- for (const method of cls.getMethods()) {
4753
+ const inherited = resolveInheritedMethods(cls);
4754
+ const mixinBinding = inherited ? {
4755
+ factoryName: inherited.factoryName,
4756
+ factoryFilePath: inherited.factoryFilePath,
4757
+ classArgs: inherited.classArgs
4758
+ } : void 0;
4759
+ const methods = [
4760
+ ...cls.getMethods().map((method) => ({ method })),
4761
+ ...(inherited?.methods ?? []).map((method) => ({ method, mixin: mixinBinding }))
4762
+ ];
4763
+ for (const { method, mixin } of methods) {
4616
4764
  const verb = resolveVerb(method);
4617
4765
  const applyContractDecorator = method.getDecorator("ApplyContract");
4618
4766
  const route = applyContractDecorator ? extractContractRoute({
@@ -4624,7 +4772,8 @@ function extractFromSourceFile(sourceFile, project) {
4624
4772
  className,
4625
4773
  sourceFile,
4626
4774
  project,
4627
- seenNames
4775
+ seenNames,
4776
+ mixin
4628
4777
  }) : extractDtoRoute({
4629
4778
  cls,
4630
4779
  method,
@@ -4633,7 +4782,8 @@ function extractFromSourceFile(sourceFile, project) {
4633
4782
  className,
4634
4783
  sourceFile,
4635
4784
  project,
4636
- seenNames
4785
+ seenNames,
4786
+ mixin
4637
4787
  });
4638
4788
  if (route) routes.push(route);
4639
4789
  }
@@ -4834,7 +4984,7 @@ async function watch(config, onChange, options = {}) {
4834
4984
  }
4835
4985
 
4836
4986
  // src/index.ts
4837
- var VERSION = "0.14.2";
4987
+ var VERSION = "0.16.0";
4838
4988
 
4839
4989
  // src/cli/codegen.ts
4840
4990
  async function runCodegen(opts = {}) {