@dudousxd/nestjs-codegen 0.12.0 → 0.13.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/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # @dudousxd/nestjs-codegen
2
2
 
3
+ ## 0.13.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 6b51c7b: fix(multipart): intersect the uploaded-file field at emit time so it survives a named `bodyRef`, and leave deliberately-loose bodies untouched.
8
+
9
+ Two fixes to the multipart upload routes shipped in 0.13.0:
10
+
11
+ - **Named body refs now include the file field.** Discovery carries the uploaded-file
12
+ field(s) in a new `multipartBody` (kept off `body`), and the emitter intersects it onto
13
+ whichever body expression it picks — a named `bodyRef` (`BaseFileUploadDto`) or the inline
14
+ text. Previously the merge lived on the inline `body` string, so a route whose `@Body`
15
+ resolved to an imported DTO emitted the plain `BaseFileUploadDto` and dropped the file
16
+ field (`api.X({ body: { ...fields, file } })` failed to type-check).
17
+
18
+ - **Deliberately-loose bodies are left alone.** A `@Body() x: SomeDto | any` handler resolves
19
+ to a top-level `unknown`/`any` union arm; intersecting `(Dto | unknown) & { file }` collapses
20
+ it and wrongly tightens the type. The emitter now detects a permissive body and skips the
21
+ intersection, keeping the author's loose `@Body()` (the route is still flagged `multipart`).
22
+
23
+ ## 0.13.0
24
+
25
+ ### Minor Changes
26
+
27
+ - a044e73: feat: typed `multipart/form-data` upload routes (`@UploadedFile()` / Multer interceptors).
28
+
29
+ The codegen now understands handlers that accept uploaded files, so multipart uploads
30
+ become first-class typed routes (`api.X({ body: { ...fields, file } })`) instead of
31
+ needing the `fetchRaw` escape hatch.
32
+
33
+ **core (`@dudousxd/nestjs-codegen`):**
34
+
35
+ - Discovery detects `@UploadedFile()` / `@UploadedFiles()` handlers and reads the HTTP
36
+ field name(s) + arity from the Multer interceptor in `@UseInterceptors(...)`:
37
+ - `FileInterceptor('file')` → `file: File | Blob`
38
+ - `FilesInterceptor('files')` → `files: Array<File | Blob>`
39
+ - `FileFieldsInterceptor([{ name: 'a' }, { name: 'b' }])` → `a: Array<File | Blob>; b: Array<File | Blob>`
40
+ - `AnyFilesInterceptor()` → flagged multipart (no statically known field names)
41
+ - The uploaded-file field(s) are merged into the route `body` as an intersection with the
42
+ `@Body` DTO (`SomeDto & { file: File | Blob }`), typed for the browser as `File | Blob`
43
+ (never the server-side `Express.Multer.File`).
44
+ - The route carries a new `multipart` flag, emitted into the generated client so the call
45
+ passes `multipart: true` to the fetcher.
46
+
47
+ **client (`@dudousxd/nestjs-client`):**
48
+
49
+ - `RequestOpts` gains `multipart?: boolean`. When set, the fetcher serializes the body
50
+ object to a `FormData` (scalars as strings, `Date` as ISO, `File`/`Blob` as file parts,
51
+ arrays as repeated parts) instead of JSON, letting the runtime set the multipart
52
+ boundary. `onUploadProgress` already rides the same path.
53
+
3
54
  ## 0.12.0
4
55
 
5
56
  ### Minor Changes
package/dist/cli/main.cjs CHANGED
@@ -785,7 +785,15 @@ function emitRouterTypeBlock(tree, indent, outDir, serialization) {
785
785
  const isFilterQuery = c.contractSource.filterSource === "query" && !!c.contractSource.filterFields?.length;
786
786
  const query = queryRef ? queryRef.isArray ? `Array<${queryRef.name}>` : queryRef.name : isFilterQuery ? emitFilterQueryType(c) : c.contractSource.query ?? "never";
787
787
  const bodyRef = c.contractSource.bodyRef;
788
- const body = method === "GET" ? "never" : bodyRef ? bodyRef.isArray ? `Array<${bodyRef.name}>` : bodyRef.name : c.contractSource.body ?? "never";
788
+ let body = method === "GET" ? "never" : bodyRef ? bodyRef.isArray ? `Array<${bodyRef.name}>` : bodyRef.name : c.contractSource.body ?? "never";
789
+ const multipartBody = c.contractSource.multipartBody;
790
+ if (c.contractSource.multipart && multipartBody) {
791
+ if (body === "never") {
792
+ body = multipartBody;
793
+ } else if (!bodyAcceptsAnything(body)) {
794
+ body = `(${body}) & ${multipartBody}`;
795
+ }
796
+ }
789
797
  const response = buildResponseType(c, outDir, serialization);
790
798
  const error = buildErrorType(c);
791
799
  const params = buildParamsType(c.params);
@@ -804,6 +812,25 @@ function emitRouterTypeBlock(tree, indent, outDir, serialization) {
804
812
  }
805
813
  return lines;
806
814
  }
815
+ function topLevelUnionArms(type) {
816
+ const arms = [];
817
+ let depth = 0;
818
+ let start = 0;
819
+ for (let i = 0; i < type.length; i++) {
820
+ const ch = type[i];
821
+ if (ch === "{" || ch === "[" || ch === "<" || ch === "(") depth++;
822
+ else if (ch === "}" || ch === "]" || ch === ">" || ch === ")") depth--;
823
+ else if (ch === "|" && depth === 0) {
824
+ arms.push(type.slice(start, i).trim());
825
+ start = i + 1;
826
+ }
827
+ }
828
+ arms.push(type.slice(start).trim());
829
+ return arms;
830
+ }
831
+ function bodyAcceptsAnything(body) {
832
+ return topLevelUnionArms(body).some((arm) => arm === "unknown" || arm === "any");
833
+ }
807
834
  function buildRequestModel(c) {
808
835
  const m = c.method.toLowerCase();
809
836
  const flat = JSON.stringify(c.name);
@@ -820,6 +847,7 @@ function buildRequestModel(c) {
820
847
  const optsParts = [];
821
848
  if (hasQuery) optsParts.push("query: input?.query as Record<string, unknown> | undefined");
822
849
  if (hasBody) optsParts.push("body: input?.body");
850
+ if (hasBody && c.contractSource.multipart) optsParts.push("multipart: true");
823
851
  const optsExpr = optsParts.length ? `{ ${optsParts.join(", ")} }` : "{}";
824
852
  return {
825
853
  routeName: c.name,
@@ -3700,6 +3728,55 @@ function extractParamsType(method, sourceFile, project) {
3700
3728
  }
3701
3729
  return entries.length > 0 ? `{ ${entries.join("; ")} }` : null;
3702
3730
  }
3731
+ function extractUploadedFiles(method) {
3732
+ const FILE = "File | Blob";
3733
+ const entries = [];
3734
+ let multipart = false;
3735
+ const hasUploadedFileParam = method.getParameters().some(
3736
+ (p) => p.getDecorators().some((d) => {
3737
+ const name = d.getName();
3738
+ return name === "UploadedFile" || name === "UploadedFiles";
3739
+ })
3740
+ );
3741
+ for (const decorator of method.getDecorators()) {
3742
+ if (decorator.getName() !== "UseInterceptors") continue;
3743
+ for (const arg of decorator.getArguments()) {
3744
+ if (!import_ts_morph7.Node.isCallExpression(arg)) continue;
3745
+ const interceptor = arg.getExpression().getText();
3746
+ const callArgs = arg.getArguments();
3747
+ const firstArg2 = callArgs[0];
3748
+ if (interceptor === "FileInterceptor") {
3749
+ if (firstArg2 && import_ts_morph7.Node.isStringLiteral(firstArg2)) {
3750
+ entries.push(`${firstArg2.getLiteralValue()}: ${FILE}`);
3751
+ multipart = true;
3752
+ }
3753
+ } else if (interceptor === "FilesInterceptor") {
3754
+ if (firstArg2 && import_ts_morph7.Node.isStringLiteral(firstArg2)) {
3755
+ entries.push(`${firstArg2.getLiteralValue()}: Array<${FILE}>`);
3756
+ multipart = true;
3757
+ }
3758
+ } else if (interceptor === "FileFieldsInterceptor") {
3759
+ if (firstArg2 && import_ts_morph7.Node.isArrayLiteralExpression(firstArg2)) {
3760
+ for (const el of firstArg2.getElements()) {
3761
+ if (!import_ts_morph7.Node.isObjectLiteralExpression(el)) continue;
3762
+ const nameProp = el.getProperty("name");
3763
+ if (nameProp && import_ts_morph7.Node.isPropertyAssignment(nameProp)) {
3764
+ const init = nameProp.getInitializer();
3765
+ if (init && import_ts_morph7.Node.isStringLiteral(init)) {
3766
+ entries.push(`${init.getLiteralValue()}: Array<${FILE}>`);
3767
+ }
3768
+ }
3769
+ }
3770
+ multipart = true;
3771
+ }
3772
+ } else if (interceptor === "AnyFilesInterceptor") {
3773
+ multipart = true;
3774
+ }
3775
+ }
3776
+ }
3777
+ if (hasUploadedFileParam) multipart = true;
3778
+ return { fields: entries.length > 0 ? entries.join("; ") : null, multipart };
3779
+ }
3703
3780
  function extractResponseType(method, sourceFile, project) {
3704
3781
  const apiResponseDecorator = method.getDecorators().find((d) => d.getName() === "ApiResponse" && (apiResponseStatus(d) ?? 0) < 400);
3705
3782
  if (apiResponseDecorator) {
@@ -3834,6 +3911,8 @@ function extractDtoContract(method, sourceFile, project) {
3834
3911
  let body = extractBodyType(method, sourceFile, project);
3835
3912
  const filterInfo = extractApplyFilterInfo(method, sourceFile, project);
3836
3913
  const query = extractQueryType(method, sourceFile, project);
3914
+ const uploads = extractUploadedFiles(method);
3915
+ const multipartBody = uploads.fields ? `{ ${uploads.fields} }` : null;
3837
3916
  const streamElement = detectStreamElement(method);
3838
3917
  const isStream = streamElement !== null;
3839
3918
  if (filterInfo && filterInfo.source === "body") {
@@ -3843,7 +3922,7 @@ function extractDtoContract(method, sourceFile, project) {
3843
3922
  const paramsType = extractParamsType(method, sourceFile, project);
3844
3923
  const response = isStream ? resolveTypeNodeToString(streamElement, sourceFile, project, 3) : extractResponseType(method, sourceFile, project);
3845
3924
  const errorInfo = extractErrorType(method, sourceFile, project);
3846
- if (body === null && query === null && paramsType === null && response === "unknown" && errorInfo === null && filterInfo === null && !isStream) {
3925
+ if (body === null && query === null && paramsType === null && response === "unknown" && errorInfo === null && filterInfo === null && !isStream && !uploads.multipart) {
3847
3926
  return null;
3848
3927
  }
3849
3928
  let bodyRef = null;
@@ -3916,7 +3995,9 @@ function extractDtoContract(method, sourceFile, project) {
3916
3995
  formWarnings,
3917
3996
  bodySchema,
3918
3997
  querySchema,
3919
- stream: isStream
3998
+ stream: isStream,
3999
+ multipart: uploads.multipart,
4000
+ multipartBody
3920
4001
  };
3921
4002
  }
3922
4003
  function resolveParamClass(method, decoratorName, sourceFile, project) {
@@ -4401,7 +4482,9 @@ function extractDtoRoute(args) {
4401
4482
  formWarnings: dtoContract?.formWarnings ?? [],
4402
4483
  bodySchema: dtoContract?.bodySchema ?? null,
4403
4484
  querySchema: dtoContract?.querySchema ?? null,
4404
- stream: dtoContract?.stream ?? false
4485
+ stream: dtoContract?.stream ?? false,
4486
+ multipart: dtoContract?.multipart ?? false,
4487
+ multipartBody: dtoContract?.multipartBody ?? null
4405
4488
  }
4406
4489
  });
4407
4490
  }
@@ -4631,7 +4714,7 @@ async function watch(config, onChange, options = {}) {
4631
4714
  }
4632
4715
 
4633
4716
  // src/index.ts
4634
- var VERSION = "0.12.0";
4717
+ var VERSION = "0.13.1";
4635
4718
 
4636
4719
  // src/cli/codegen.ts
4637
4720
  async function runCodegen(opts = {}) {