@nestia/core 14.0.0 → 14.0.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.
Files changed (98) hide show
  1. package/MIGRATION.md +20 -0
  2. package/README.md +1 -1
  3. package/lib/adaptors/McpAdaptor.d.ts +5 -0
  4. package/lib/adaptors/McpAdaptor.js +114 -26
  5. package/lib/adaptors/McpAdaptor.js.map +1 -1
  6. package/lib/adaptors/WebSocketAdaptor.js +124 -32
  7. package/lib/adaptors/WebSocketAdaptor.js.map +1 -1
  8. package/lib/adaptors/internal/RoutePathMatcher.d.ts +1 -0
  9. package/lib/adaptors/internal/RoutePathMatcher.js +88 -0
  10. package/lib/adaptors/internal/RoutePathMatcher.js.map +1 -0
  11. package/lib/adaptors/internal/external_context.d.ts +1 -0
  12. package/lib/adaptors/internal/external_context.js +43 -0
  13. package/lib/adaptors/internal/external_context.js.map +1 -0
  14. package/lib/decorators/EncryptedBody.js +2 -2
  15. package/lib/decorators/EncryptedBody.js.map +1 -1
  16. package/lib/decorators/EncryptedController.js +1 -1
  17. package/lib/decorators/EncryptedController.js.map +1 -1
  18. package/lib/decorators/EncryptedModule.d.ts +7 -3
  19. package/lib/decorators/EncryptedModule.js +69 -15
  20. package/lib/decorators/EncryptedModule.js.map +1 -1
  21. package/lib/decorators/EncryptedRoute.js +7 -2
  22. package/lib/decorators/EncryptedRoute.js.map +1 -1
  23. package/lib/decorators/TypedBody.d.ts +1 -1
  24. package/lib/decorators/TypedBody.js +1 -1
  25. package/lib/decorators/TypedFormData.d.ts +22 -8
  26. package/lib/decorators/TypedFormData.js +116 -31
  27. package/lib/decorators/TypedFormData.js.map +1 -1
  28. package/lib/decorators/TypedParam.d.ts +3 -3
  29. package/lib/decorators/TypedParam.js +3 -3
  30. package/lib/decorators/TypedQuery.d.ts +1 -1
  31. package/lib/decorators/TypedQuery.js +9 -4
  32. package/lib/decorators/TypedQuery.js.map +1 -1
  33. package/lib/decorators/internal/EncryptedConstant.js +13 -2
  34. package/lib/decorators/internal/EncryptedConstant.js.map +1 -1
  35. package/lib/decorators/internal/get_encryption_password.d.ts +1 -0
  36. package/lib/decorators/internal/get_encryption_password.js +14 -0
  37. package/lib/decorators/internal/get_encryption_password.js.map +1 -0
  38. package/lib/decorators/internal/get_path_and_querify.js +21 -3
  39. package/lib/decorators/internal/get_path_and_querify.js.map +1 -1
  40. package/lib/decorators/internal/get_text_body.js +9 -3
  41. package/lib/decorators/internal/get_text_body.js.map +1 -1
  42. package/lib/decorators/internal/group_entries.d.ts +1 -0
  43. package/lib/decorators/internal/group_entries.js +19 -0
  44. package/lib/decorators/internal/group_entries.js.map +1 -0
  45. package/lib/decorators/internal/validate_request_form_data.js +3 -1
  46. package/lib/decorators/internal/validate_request_form_data.js.map +1 -1
  47. package/lib/decorators/internal/validate_request_headers.js +2 -1
  48. package/lib/decorators/internal/validate_request_headers.js.map +1 -1
  49. package/lib/decorators/internal/validate_request_query.js +3 -1
  50. package/lib/decorators/internal/validate_request_query.js.map +1 -1
  51. package/lib/options/IResponseBodyQuerifier.d.ts +5 -1
  52. package/lib/utils/ExceptionManager.d.ts +3 -4
  53. package/lib/utils/ExceptionManager.js +10 -6
  54. package/lib/utils/ExceptionManager.js.map +1 -1
  55. package/lib/utils/SourceFinder.js +26 -3
  56. package/lib/utils/SourceFinder.js.map +1 -1
  57. package/lib/utils/VersioningStrategy.d.ts +9 -1
  58. package/lib/utils/VersioningStrategy.js +18 -6
  59. package/lib/utils/VersioningStrategy.js.map +1 -1
  60. package/native/go.mod +1 -1
  61. package/native/go.sum +2 -2
  62. package/native/transform/ast.go +4 -2
  63. package/native/transform/core_querify.go +26 -6
  64. package/native/transform/core_transform.go +76 -1
  65. package/native/transform/core_websocket.go +94 -13
  66. package/native/transform/node_transform.go +6 -0
  67. package/native/transform/transform.go +8 -18
  68. package/native/transform/typia_fast.go +6 -0
  69. package/native/transform.cjs +49 -0
  70. package/package.json +12 -15
  71. package/src/adaptors/McpAdaptor.ts +150 -28
  72. package/src/adaptors/WebSocketAdaptor.ts +136 -38
  73. package/src/adaptors/internal/RoutePathMatcher.ts +91 -0
  74. package/src/adaptors/internal/external_context.ts +50 -0
  75. package/src/decorators/EncryptedBody.ts +2 -2
  76. package/src/decorators/EncryptedController.ts +6 -2
  77. package/src/decorators/EncryptedModule.ts +71 -15
  78. package/src/decorators/EncryptedRoute.ts +7 -5
  79. package/src/decorators/TypedBody.ts +1 -1
  80. package/src/decorators/TypedFormData.ts +148 -44
  81. package/src/decorators/TypedParam.ts +3 -3
  82. package/src/decorators/TypedQuery.ts +19 -7
  83. package/src/decorators/internal/EncryptedConstant.ts +14 -1
  84. package/src/decorators/internal/get_encryption_password.ts +19 -0
  85. package/src/decorators/internal/get_path_and_querify.ts +29 -2
  86. package/src/decorators/internal/get_text_body.ts +8 -4
  87. package/src/decorators/internal/group_entries.ts +17 -0
  88. package/src/decorators/internal/validate_request_form_data.ts +3 -1
  89. package/src/decorators/internal/validate_request_headers.ts +2 -2
  90. package/src/decorators/internal/validate_request_query.ts +3 -2
  91. package/src/options/IResponseBodyQuerifier.ts +6 -1
  92. package/src/utils/ExceptionManager.ts +10 -6
  93. package/src/utils/SourceFinder.ts +24 -3
  94. package/src/utils/VersioningStrategy.ts +21 -7
  95. package/lib/transform.d.ts +0 -10
  96. package/lib/transform.js +0 -25
  97. package/lib/transform.js.map +0 -1
  98. package/src/transform.ts +0 -26
@@ -0,0 +1,91 @@
1
+ import { Token, match, parse } from "path-to-regexp";
2
+
3
+ /**
4
+ * A WebSocket route's path, matched as NestJS 11's Express router matches an
5
+ * HTTP route: path-to-regexp 8's syntax, where `\:` is a literal colon, `*name`
6
+ * a wildcard, and `{...}` an optional group; find-my-way's (Fastify) literal
7
+ * colon `::` is read too, as `@nestia/sdk` reads it when it builds the SDK's
8
+ * URL.
9
+ *
10
+ * @internal
11
+ */
12
+ export class RoutePathMatcher {
13
+ /** The names of the path's parameters and wildcards, in order. */
14
+ public readonly params: string[];
15
+
16
+ private readonly match_: (
17
+ path: string,
18
+ ) => false | { params: Partial<Record<string, string | string[]>> };
19
+
20
+ public constructor(private readonly path: string) {
21
+ const route: string = fromFastifyColons(path);
22
+ this.params = names(parse(route).tokens);
23
+ this.match_ = match(route, {
24
+ decode: (value: string) => {
25
+ try {
26
+ return decodeURIComponent(value);
27
+ } catch {
28
+ return value;
29
+ }
30
+ },
31
+ });
32
+ }
33
+
34
+ /**
35
+ * The parameters of a matching request path, or `null`. A wildcard's segments
36
+ * are joined by `/`, and an optional parameter absent is left out.
37
+ */
38
+ public test(path: string): Record<string, string> | null {
39
+ const matched = this.match_(path);
40
+ if (matched === false) return null;
41
+ const output: Record<string, string> = {};
42
+ for (const [key, value] of Object.entries(matched.params))
43
+ if (value !== undefined)
44
+ output[key] = Array.isArray(value) ? value.join("/") : value;
45
+ return output;
46
+ }
47
+
48
+ public toString(): string {
49
+ return this.path;
50
+ }
51
+ }
52
+
53
+ const names = (tokens: Token[]): string[] =>
54
+ tokens.flatMap((token) =>
55
+ token.type === "param" || token.type === "wildcard"
56
+ ? [token.name]
57
+ : token.type === "group"
58
+ ? names(token.tokens)
59
+ : [],
60
+ );
61
+
62
+ /**
63
+ * A route with find-my-way's (Fastify) literal colon `::` spelled as
64
+ * path-to-regexp's `\:`. find-my-way reads `::` as a colon in static text
65
+ * alone: after a parameter it is part of the parameter's name, so it is left as
66
+ * is there, and path-to-regexp then rejects the route, as no parameter of that
67
+ * name is what the handler reads.
68
+ *
69
+ * @internal
70
+ */
71
+ const fromFastifyColons = (route: string): string => {
72
+ let output: string = "";
73
+ for (let i: number = 0; i < route.length; ) {
74
+ if (route[i] === "\\") {
75
+ output += route.slice(i, i + 2);
76
+ i += 2;
77
+ } else if (route.startsWith("::", i)) {
78
+ output += "\\:";
79
+ i += 2;
80
+ } else if (route[i] === ":") {
81
+ const name: string = /^:[A-Za-z0-9_$]*/.exec(route.slice(i))![0];
82
+ output += name;
83
+ i += name.length;
84
+ if (route.startsWith("::", i)) {
85
+ output += "::";
86
+ i += 2;
87
+ }
88
+ } else output += route[i++];
89
+ }
90
+ return output;
91
+ };
@@ -0,0 +1,50 @@
1
+ import { ContextId, ContextIdFactory, NestContainer } from "@nestjs/core";
2
+ import { ExternalContextCreator } from "@nestjs/core/helpers/external-context-creator";
3
+ import { REQUEST_CONTEXT_ID } from "@nestjs/core/router/request/request-constants";
4
+
5
+ /**
6
+ * The creator of NestJS's enhancer pipeline (guards, interceptors, pipes, and
7
+ * exception filters) for the controllers of one module.
8
+ *
9
+ * `ExternalContextCreator` looks the module up among those providing the class,
10
+ * and a controller is provided by none, so its enhancers would not resolve: the
11
+ * module is named here instead.
12
+ *
13
+ * @internal
14
+ */
15
+ export const create_external_context_creator = (
16
+ container: NestContainer,
17
+ moduleKey: string,
18
+ ): ExternalContextCreator => {
19
+ const creator: ExternalContextCreator =
20
+ ExternalContextCreator.fromContainer(container);
21
+ creator.getContextModuleKey = () => moduleKey;
22
+ return creator;
23
+ };
24
+
25
+ /**
26
+ * The context a request-scoped provider is built in for this request, the one
27
+ * NestJS's router attaches to it, registering the request as `REQUEST`.
28
+ *
29
+ * @internal
30
+ */
31
+ export const get_request_context_id = (
32
+ container: NestContainer,
33
+ request: any,
34
+ durable: boolean,
35
+ ): ContextId => {
36
+ const contextId: ContextId = ContextIdFactory.getByRequest(request);
37
+ if (!request[REQUEST_CONTEXT_ID]) {
38
+ Object.defineProperty(request, REQUEST_CONTEXT_ID, {
39
+ value: contextId,
40
+ enumerable: false,
41
+ writable: false,
42
+ configurable: false,
43
+ });
44
+ container.registerRequestProvider(
45
+ durable ? contextId.payload : Object.assign(request, contextId.payload),
46
+ contextId,
47
+ );
48
+ }
49
+ return contextId;
50
+ };
@@ -10,7 +10,7 @@ import type { FastifyRequest } from "fastify";
10
10
 
11
11
  import { IRequestBodyValidator } from "../options/IRequestBodyValidator";
12
12
  import { Singleton } from "../utils/Singleton";
13
- import { ENCRYPTION_METADATA_KEY } from "./internal/EncryptedConstant";
13
+ import { get_encryption_password } from "./internal/get_encryption_password";
14
14
  import { get_text_body } from "./internal/get_text_body";
15
15
  import { headers_to_object } from "./internal/headers_to_object";
16
16
  import { is_media_type } from "./internal/is_media_type";
@@ -52,7 +52,7 @@ export function EncryptedBody<T>(
52
52
  throw new BadRequestException(`Request body type is not "text/plain".`);
53
53
 
54
54
  const param: IEncryptionPassword | IEncryptionPassword.Closure | undefined =
55
- Reflect.getMetadata(ENCRYPTION_METADATA_KEY, context.getClass());
55
+ get_encryption_password(context.getClass());
56
56
  if (!param)
57
57
  throw new Error(
58
58
  "Error on nestia.core.EncryptedBody(): no encryption password is given.",
@@ -1,7 +1,7 @@
1
1
  import { IEncryptionPassword } from "@nestia/fetcher";
2
2
  import { Controller } from "@nestjs/common";
3
3
 
4
- import { ENCRYPTION_METADATA_KEY } from "./internal/EncryptedConstant";
4
+ import { ENCRYPTION_CONTROLLER_METADATA_KEY } from "./internal/EncryptedConstant";
5
5
 
6
6
  /**
7
7
  * Encrypted controller.
@@ -34,7 +34,11 @@ export function EncryptedController(
34
34
  password: IEncryptionPassword | IEncryptionPassword.Closure,
35
35
  ): ClassDecorator {
36
36
  return function (target: any) {
37
- Reflect.defineMetadata(ENCRYPTION_METADATA_KEY, password, target);
37
+ Reflect.defineMetadata(
38
+ ENCRYPTION_CONTROLLER_METADATA_KEY,
39
+ password,
40
+ target,
41
+ );
38
42
  Controller(path)(target);
39
43
  };
40
44
  }
@@ -16,9 +16,13 @@ import { load_controllers } from "./internal/load_controller";
16
16
  *
17
17
  * By using this `EncryptedModule` decorator function, all of the
18
18
  * {@link Controller controllers} configured in the _metadata_ would be
19
- * automatically changed to the {@link EncryptedController} with the _password_.
20
- * If there're some original {@link EncryptedController} decorated classes in the
21
- * _metadata_, their encryption password would be kept.
19
+ * automatically changed to the {@link EncryptedController} with the _password_,
20
+ * and so would the controllers of every module it imports: module classes,
21
+ * dynamic modules (`{ module, imports, controllers }`), `forwardRef()`s, and
22
+ * promises of them, however deep and even when cyclic. If there're some
23
+ * original {@link EncryptedController} decorated classes in the _metadata_,
24
+ * their encryption password would be kept, as would a subclass's that inherits
25
+ * one.
22
26
  *
23
27
  * Therefore, if you're planning to place original {@link EncryptedController}
24
28
  * decorated classes in the _metadata_, I hope them to have different encryption
@@ -81,18 +85,70 @@ export namespace EncryptedModule {
81
85
  }
82
86
  }
83
87
 
84
- /** @internal */
85
- const iterate =
86
- (password: IEncryptionPassword.Closure) =>
87
- (modulo: any): void => {
88
- const imports = Reflect.getMetadata("imports", modulo);
89
- if (Array.isArray(imports))
90
- for (const imp of imports)
91
- if (typeof imp === "function") iterate(password)(imp);
92
-
93
- const controllers = Reflect.getMetadata("controllers", modulo);
94
- if (Array.isArray(controllers))
95
- for (const c of controllers)
88
+ /**
89
+ * Gives every controller the module reaches the module's password: those of the
90
+ * module itself and of each import, whether a module class, a dynamic module
91
+ * (its `module`, and its own `imports` and `controllers`), a `forwardRef()`, or
92
+ * a promise of one. Each module is visited once, so a cyclic import graph
93
+ * terminates.
94
+ *
95
+ * @internal
96
+ */
97
+ const iterate = (password: IEncryptionPassword.Closure) => {
98
+ const visited: Set<object> = new Set();
99
+ const controllers = (list: unknown): void => {
100
+ if (Array.isArray(list))
101
+ for (const c of list)
96
102
  if (typeof c === "function")
97
103
  Reflect.defineMetadata(ENCRYPTION_METADATA_KEY, password, c);
98
104
  };
105
+ const imports = (list: unknown): void => {
106
+ if (Array.isArray(list)) for (const imp of list) visit(imp);
107
+ };
108
+ const visit = (input: unknown): void => {
109
+ if (
110
+ typeof input !== "function" &&
111
+ (typeof input !== "object" || input === null)
112
+ )
113
+ return;
114
+ if (visited.has(input)) return;
115
+ visited.add(input);
116
+
117
+ if (typeof input === "function") {
118
+ imports(Reflect.getMetadata("imports", input));
119
+ controllers(Reflect.getMetadata("controllers", input));
120
+ } else if (typeof (input as PromiseLike<unknown>).then === "function")
121
+ // an asynchronous dynamic module; NestJS awaits the same promise while
122
+ // scanning, after this callback, registered first, has run
123
+ (input as PromiseLike<unknown>).then(visit, () => {});
124
+ else if (typeof (input as IForwardReference).forwardRef === "function") {
125
+ // the class a forward reference names may not be defined yet while the
126
+ // decorators run; it is once the synchronous module graph has loaded
127
+ const resolve = (): unknown => {
128
+ try {
129
+ return (input as IForwardReference).forwardRef();
130
+ } catch {
131
+ return undefined;
132
+ }
133
+ };
134
+ const target: unknown = resolve();
135
+ if (target !== undefined) visit(target);
136
+ else void Promise.resolve().then(() => visit(resolve()));
137
+ } else {
138
+ const dynamic = input as {
139
+ module?: unknown;
140
+ imports?: unknown;
141
+ controllers?: unknown;
142
+ };
143
+ visit(dynamic.module);
144
+ imports(dynamic.imports);
145
+ controllers(dynamic.controllers);
146
+ }
147
+ };
148
+ return visit;
149
+ };
150
+
151
+ /** @internal */
152
+ interface IForwardReference {
153
+ forwardRef: () => unknown;
154
+ }
@@ -20,7 +20,7 @@ import typia from "typia";
20
20
  import { IResponseBodyStringifier } from "../options/IResponseBodyStringifier";
21
21
  import { Singleton } from "../utils/Singleton";
22
22
  import { TypedRoute } from "./TypedRoute";
23
- import { ENCRYPTION_METADATA_KEY } from "./internal/EncryptedConstant";
23
+ import { get_encryption_password } from "./internal/get_encryption_password";
24
24
  import { get_path_and_stringify } from "./internal/get_path_and_stringify";
25
25
  import { headers_to_object } from "./internal/headers_to_object";
26
26
  import { route_error } from "./internal/route_error";
@@ -168,10 +168,7 @@ class EncryptedRouteInterceptor implements NestInterceptor {
168
168
  const param:
169
169
  | IEncryptionPassword
170
170
  | IEncryptionPassword.Closure
171
- | undefined = Reflect.getMetadata(
172
- ENCRYPTION_METADATA_KEY,
173
- context.getClass(),
174
- );
171
+ | undefined = get_encryption_password(context.getClass());
175
172
  if (!param)
176
173
  throw new Error(
177
174
  `Error on EncryptedRoute.${this.method}(): no password found.`,
@@ -196,6 +193,11 @@ class EncryptedRouteInterceptor implements NestInterceptor {
196
193
  : param;
197
194
 
198
195
  if (body === undefined) return body;
196
+ // the ciphertext is text/plain, as the document declares; set on the
197
+ // success path alone, so an error keeps its JSON body's type (Express
198
+ // would otherwise send a string as text/html)
199
+ const response: express.Response = http.getResponse();
200
+ response.header("Content-Type", "text/plain");
199
201
  return AesPkcs5.encrypt(body, password.key, password.iv);
200
202
  }),
201
203
  catchError((err) => route_error(http.getRequest(), err)),
@@ -23,7 +23,7 @@ import { validate_request_body } from "./internal/validate_request_body";
23
23
  * `T`, `BadRequestException` error (status code: 400) would be thrown.
24
24
  *
25
25
  * @author Jeongho Nam - https://github.com/samchon
26
- * @param validator Custom validator if required. Default is `typia.assert()`
26
+ * @param validator Custom validator if required. Default is `typia.validate()`
27
27
  */
28
28
  export function TypedBody<T>(
29
29
  validator?: IRequestBodyValidator<T>,
@@ -1,10 +1,13 @@
1
1
  import {
2
2
  BadRequestException,
3
3
  ExecutionContext,
4
+ HttpException,
5
+ PayloadTooLargeException,
4
6
  createParamDecorator,
5
7
  } from "@nestjs/common";
6
8
  import type { HttpArgumentsHost } from "@nestjs/common/interfaces";
7
9
  import type express from "express";
10
+ import fs from "fs";
8
11
  import type ExpressMulter from "multer";
9
12
 
10
13
  import type { IRequestFormDataProps } from "../options/IRequestFormDataProps";
@@ -35,10 +38,13 @@ import { validate_request_form_data } from "./internal/validate_request_form_dat
35
38
  * types are allowed
36
39
  * 4. By the way, union type never be not allowed
37
40
  *
38
- * By the way, if you're using `fastify`, you have to setup `fastify-multer` and
39
- * configure like below when composing the NestJS application. If you don't do
40
- * that, `@TypedFormData.Body()` will not work properly, and throw 500 internal
41
- * server error when `Blob` or `File` type being utilized.
41
+ * By the way, if you're using `fastify`, pass a `fastify-multer` instance as
42
+ * the factory, and register a `multipart/form-data` content type parser that
43
+ * leaves the request stream to it when composing the NestJS application.
44
+ * Without the parser, Fastify rejects every multipart request with 415.
45
+ * `fastify-multer`'s own `contentParser` plugin declares the content type
46
+ * `multipart` without a subtype, which Fastify 5 (NestJS 11) refuses to
47
+ * register.
42
48
  *
43
49
  * ```typescript
44
50
  * import { NestFactory } from "@nestjs/core";
@@ -46,16 +52,25 @@ import { validate_request_form_data } from "./internal/validate_request_form_dat
46
52
  * FastifyAdapter,
47
53
  * NestFastifyApplication,
48
54
  * } from "@nestjs/platform-fastify";
49
- * import fastifyMulter from "fastify-multer";
50
55
  *
51
56
  * export async function main() {
52
57
  * const app = await NestFactory.create<NestFastifyApplication>(
53
58
  * AppModule,
54
59
  * new FastifyAdapter(),
55
60
  * );
56
- * app.register(fastifyMulter.contentParser);
61
+ * app
62
+ * .getHttpAdapter()
63
+ * .getInstance()
64
+ * .addContentTypeParser("multipart/form-data", (_req, _payload, done) =>
65
+ * done(null),
66
+ * );
57
67
  * await app.listen(3000);
58
68
  * }
69
+ *
70
+ * // in the controller, with `import FastifyMulter from "fastify-multer"`
71
+ * public async upload(
72
+ * @TypedFormData.Body(() => FastifyMulter()) body: IMultipart,
73
+ * ): Promise<void> {}
59
74
  * ```
60
75
  *
61
76
  * @author Jeongho Nam - https://github.com/samchon
@@ -69,9 +84,11 @@ export namespace TypedFormData {
69
84
  *
70
85
  * Much easier and type safer than `@nest.UploadFile()` decorator.
71
86
  *
72
- * @param factory Factory function ncreating the `multer` or `fastify-multer`
87
+ * @param factory Factory function creating the `multer` or `fastify-multer`
73
88
  * instance. In the factory function, you also can specify the multer
74
- * composition options like `storage` engine.
89
+ * composition options like `storage` engine: memory and disk storage
90
+ * deliver the uploaded bytes, and an engine keeping neither the buffer nor
91
+ * a file path is rejected.
75
92
  */
76
93
  export function Body<Multer extends IMulterBase>(
77
94
  factory: () => Multer | Promise<Multer>,
@@ -88,7 +105,7 @@ export namespace TypedFormData {
88
105
  );
89
106
  const checker = validate_request_form_data(props);
90
107
  const uploader = new Singleton(async () =>
91
- decode((await factory()) as ExpressMulter.Multer, props!),
108
+ decode((await factory()) as ExpressMulter.Multer, props),
92
109
  );
93
110
  return createParamDecorator(async function TypedFormDataBody(
94
111
  _unknown: any,
@@ -130,18 +147,22 @@ export namespace TypedFormData {
130
147
  /** @internal */
131
148
  const decode = <T>(
132
149
  multer: ExpressMulter.Multer,
133
- props: IRequestFormDataProps<T>,
150
+ props: IRequestFormDataProps<T> | undefined,
134
151
  ) => {
135
- const upload = multer.fields(
136
- props!.files.map((file) => ({
137
- name: file.name,
138
- ...(file.limit === 1 ? { maxCount: 1 } : {}),
139
- })),
140
- );
152
+ // without the transform nothing names the file fields, so accept every file
153
+ const upload =
154
+ props === undefined
155
+ ? multer.any()
156
+ : multer.fields(
157
+ props.files.map((file) => ({
158
+ name: file.name,
159
+ ...(file.limit === 1 ? { maxCount: 1 } : {}),
160
+ })),
161
+ );
141
162
  const interceptor = (request: express.Request, response: express.Response) =>
142
163
  new Promise<void>((resolve, reject) =>
143
164
  upload(request, response, (error) => {
144
- if (error) reject(error);
165
+ if (error) reject(transformMulterError(error));
145
166
  else resolve();
146
167
  }),
147
168
  );
@@ -151,35 +172,118 @@ const decode = <T>(
151
172
  }): Promise<FormData> => {
152
173
  await interceptor(socket.request, socket.response);
153
174
 
154
- const data: FormData = new FormData();
155
- for (const [key, value] of Object.entries(socket.request.body))
156
- if (Array.isArray(value))
157
- for (const elem of value) data.append(key, String(elem));
158
- else data.append(key, String(value));
159
- if (socket.request.files) parseFiles(data)(socket.request.files);
160
- return data;
175
+ const files: Array<[string, Express.Multer.File]> = entries(
176
+ socket.request.files,
177
+ );
178
+ try {
179
+ const data: FormData = new FormData();
180
+ for (const [key, value] of Object.entries(socket.request.body))
181
+ if (Array.isArray(value))
182
+ for (const elem of value) data.append(key, String(elem));
183
+ else data.append(key, String(value));
184
+ for (const [key, file] of files) data.append(key, await toFile(file));
185
+ return data;
186
+ } finally {
187
+ // a disk storage engine leaves each file behind, and the handler only
188
+ // receives its contents as a File, never the path to remove it by
189
+ await Promise.all(
190
+ files.map(([, file]) =>
191
+ typeof file.path === "string"
192
+ ? fs.promises.rm(file.path, { force: true })
193
+ : undefined,
194
+ ),
195
+ );
196
+ }
161
197
  };
162
198
  };
163
199
 
200
+ /**
201
+ * The client error NestJS's own `FileInterceptor` answers for a multipart
202
+ * request the multer configuration rejects: 413 for a file over
203
+ * `limits.fileSize`, 400 for the other limits, an unexpected field, and a
204
+ * malformed body. Anything else, an `HttpException` a `fileFilter` threw
205
+ * included, is left as it is.
206
+ *
207
+ * @internal
208
+ */
209
+ const transformMulterError = (error: unknown): unknown => {
210
+ if (!(error instanceof Error) || error instanceof HttpException) return error;
211
+ const code: unknown = (error as { code?: unknown }).code;
212
+ const field: unknown = (error as { field?: unknown }).field;
213
+ if (code === "LIMIT_FILE_SIZE")
214
+ return new PayloadTooLargeException(error.message);
215
+ else if (typeof code === "string" && MULTER_BAD_REQUESTS.has(code))
216
+ return new BadRequestException(
217
+ typeof field === "string" ? `${error.message} - ${field}` : error.message,
218
+ );
219
+ else if (error.message === "Multipart: Boundary not found")
220
+ return new BadRequestException(error.message);
221
+ else if (
222
+ BUSBOY_BAD_REQUESTS.has(error.message) ||
223
+ error.message.endsWith(
224
+ " terminated early due to unexpected end of multipart data",
225
+ )
226
+ )
227
+ return new BadRequestException(`Multipart: ${error.message}`);
228
+ return error;
229
+ };
230
+
164
231
  /** @internal */
165
- const parseFiles =
166
- (data: FormData) =>
167
- (files: Express.Multer.File[] | Record<string, Express.Multer.File[]>) => {
168
- if (Array.isArray(files))
169
- for (const file of files)
170
- data.append(
171
- file.fieldname,
172
- new File([file.buffer as any], file.originalname, {
173
- type: file.mimetype,
174
- }),
232
+ const MULTER_BAD_REQUESTS: Set<string> = new Set([
233
+ "LIMIT_PART_COUNT",
234
+ "LIMIT_FILE_COUNT",
235
+ "LIMIT_FIELD_KEY",
236
+ "LIMIT_FIELD_VALUE",
237
+ "LIMIT_FIELD_COUNT",
238
+ "LIMIT_UNEXPECTED_FILE",
239
+ "MISSING_FIELD_NAME",
240
+ ]);
241
+
242
+ /**
243
+ * A malformed body, as `busboy` (under `multer`) and `@fastify/busboy` (under
244
+ * `fastify-multer`) word it.
245
+ *
246
+ * @internal
247
+ */
248
+ const BUSBOY_BAD_REQUESTS: Set<string> = new Set([
249
+ "Malformed part header",
250
+ "Unexpected end of form",
251
+ "Unexpected end of file",
252
+ "Unexpected end of multipart data",
253
+ ]);
254
+
255
+ /** @internal */
256
+ const entries = (
257
+ files:
258
+ | Express.Multer.File[]
259
+ | Record<string, Express.Multer.File[]>
260
+ | undefined,
261
+ ): Array<[string, Express.Multer.File]> =>
262
+ files === undefined
263
+ ? []
264
+ : Array.isArray(files)
265
+ ? files.map((file) => [file.fieldname, file])
266
+ : Object.entries(files).flatMap(([key, value]) =>
267
+ value.map((file): [string, Express.Multer.File] => [key, file]),
175
268
  );
176
- else
177
- for (const [key, value] of Object.entries(files))
178
- for (const file of value)
179
- data.append(
180
- key,
181
- new File([file.buffer as any], file.originalname, {
182
- type: file.mimetype,
183
- }),
184
- );
185
- };
269
+
270
+ /**
271
+ * The uploaded file as a `File`: memory storage keeps its bytes in `buffer`,
272
+ * disk storage in the file at `path`. An engine keeping neither cannot deliver
273
+ * it, which must not pass as an empty or placeholder file.
274
+ *
275
+ * @internal
276
+ */
277
+ const toFile = async (file: Express.Multer.File): Promise<File> => {
278
+ const bytes: Buffer | undefined =
279
+ file.buffer !== undefined
280
+ ? file.buffer
281
+ : typeof file.path === "string"
282
+ ? await fs.promises.readFile(file.path)
283
+ : undefined;
284
+ if (bytes === undefined)
285
+ throw new Error(
286
+ `Error on TypedFormData.Body(): the multer storage engine kept neither the buffer nor the path of the uploaded file ${JSON.stringify(file.fieldname)}, so it cannot be read as a File. Use memory or disk storage.`,
287
+ );
288
+ return new File([bytes as any], file.originalname, { type: file.mimetype });
289
+ };
@@ -22,9 +22,9 @@ import { NoTransformConfigurationError } from "./NoTransformConfigurationError";
22
22
  *
23
23
  * \@TypedRoute.Get("shopping/sales/:id/:no/:paused")
24
24
  * public async pause(
25
- * \@TypedParam("id", "uuid"), id: string & tags.Format<"uuid">,
26
- * \@TypedParam("no") id: number & tags.Type<"uint32">
27
- * \@TypedParam("paused") paused: boolean | null
25
+ * \@TypedParam("id") id: string & tags.Format<"uuid">,
26
+ * \@TypedParam("no") no: number & tags.Type<"uint32">,
27
+ * \@TypedParam("paused") paused: boolean | null,
28
28
  * ): Promise<void>;
29
29
  * ```
30
30
  *
@@ -15,11 +15,12 @@ import {
15
15
  import { HttpArgumentsHost } from "@nestjs/common/interfaces";
16
16
  import type express from "express";
17
17
  import type { FastifyRequest } from "fastify";
18
- import { catchError, map } from "rxjs";
18
+ import { catchError, map } from "rxjs/operators";
19
19
  import typia from "typia";
20
20
 
21
21
  import { IRequestQueryValidator } from "../options/IRequestQueryValidator";
22
22
  import { IResponseBodyQuerifier } from "../options/IResponseBodyQuerifier";
23
+ import { TypedRoute } from "./TypedRoute";
23
24
  import { get_path_and_querify } from "./internal/get_path_and_querify";
24
25
  import { is_media_type } from "./internal/is_media_type";
25
26
  import { route_error } from "./internal/route_error";
@@ -117,7 +118,7 @@ export namespace TypedQuery {
117
118
  export const Post = Generator("Post");
118
119
 
119
120
  /**
120
- * Router decorator function for the PATH method.
121
+ * Router decorator function for the PATCH method.
121
122
  *
122
123
  * @param path Path of the HTTP request
123
124
  * @returns Method decorator
@@ -150,9 +151,9 @@ export namespace TypedQuery {
150
151
  ): MethodDecorator;
151
152
 
152
153
  function route(...args: any[]): MethodDecorator {
153
- const [path, stringify] = get_path_and_querify(`TypedQuery.${method}`)(
154
- ...args,
155
- );
154
+ const [path, stringify] = get_path_and_querify(() => TypedRoute.__logger)(
155
+ `TypedQuery.${method}`,
156
+ )(...args);
156
157
  return applyDecorators(
157
158
  ROUTERS[method](path),
158
159
  UseInterceptors(new TypedQueryRouteInterceptor(stringify)),
@@ -186,6 +187,10 @@ class FakeURLSearchParams {
186
187
  return this.target[key] !== undefined;
187
188
  }
188
189
 
190
+ public keys(): string[] {
191
+ return Object.keys(this.target).filter((key) => this.has(key));
192
+ }
193
+
189
194
  public get(key: string): string | null {
190
195
  const value = this.target[key];
191
196
  return value === undefined
@@ -204,16 +209,23 @@ class FakeURLSearchParams {
204
209
  /** @internal */
205
210
  class TypedQueryRouteInterceptor implements NestInterceptor {
206
211
  public constructor(
207
- private readonly toSearchParams: (input: any) => URLSearchParams,
212
+ private readonly toSearchParams: (
213
+ input: any,
214
+ method: string,
215
+ path: string,
216
+ ) => URLSearchParams,
208
217
  ) {}
209
218
 
210
219
  public intercept(context: ExecutionContext, next: CallHandler) {
211
220
  const http: HttpArgumentsHost = context.switchToHttp();
221
+ const request: express.Request = http.getRequest();
212
222
  const response: express.Response = http.getResponse();
213
223
  response.header("Content-Type", "application/x-www-form-urlencoded");
214
224
 
215
225
  return next.handle().pipe(
216
- map((value) => this.toSearchParams(value).toString()),
226
+ map((value) =>
227
+ this.toSearchParams(value, request.method, request.url).toString(),
228
+ ),
217
229
  catchError((err) => route_error(http.getRequest(), err)),
218
230
  );
219
231
  }