@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.
- package/MIGRATION.md +20 -0
- package/README.md +1 -1
- package/lib/adaptors/McpAdaptor.d.ts +5 -0
- package/lib/adaptors/McpAdaptor.js +114 -26
- package/lib/adaptors/McpAdaptor.js.map +1 -1
- package/lib/adaptors/WebSocketAdaptor.js +124 -32
- package/lib/adaptors/WebSocketAdaptor.js.map +1 -1
- package/lib/adaptors/internal/RoutePathMatcher.d.ts +1 -0
- package/lib/adaptors/internal/RoutePathMatcher.js +88 -0
- package/lib/adaptors/internal/RoutePathMatcher.js.map +1 -0
- package/lib/adaptors/internal/external_context.d.ts +1 -0
- package/lib/adaptors/internal/external_context.js +43 -0
- package/lib/adaptors/internal/external_context.js.map +1 -0
- package/lib/decorators/EncryptedBody.js +2 -2
- package/lib/decorators/EncryptedBody.js.map +1 -1
- package/lib/decorators/EncryptedController.js +1 -1
- package/lib/decorators/EncryptedController.js.map +1 -1
- package/lib/decorators/EncryptedModule.d.ts +7 -3
- package/lib/decorators/EncryptedModule.js +69 -15
- package/lib/decorators/EncryptedModule.js.map +1 -1
- package/lib/decorators/EncryptedRoute.js +7 -2
- package/lib/decorators/EncryptedRoute.js.map +1 -1
- package/lib/decorators/TypedBody.d.ts +1 -1
- package/lib/decorators/TypedBody.js +1 -1
- package/lib/decorators/TypedFormData.d.ts +22 -8
- package/lib/decorators/TypedFormData.js +116 -31
- package/lib/decorators/TypedFormData.js.map +1 -1
- package/lib/decorators/TypedParam.d.ts +3 -3
- package/lib/decorators/TypedParam.js +3 -3
- package/lib/decorators/TypedQuery.d.ts +1 -1
- package/lib/decorators/TypedQuery.js +9 -4
- package/lib/decorators/TypedQuery.js.map +1 -1
- package/lib/decorators/internal/EncryptedConstant.js +13 -2
- package/lib/decorators/internal/EncryptedConstant.js.map +1 -1
- package/lib/decorators/internal/get_encryption_password.d.ts +1 -0
- package/lib/decorators/internal/get_encryption_password.js +14 -0
- package/lib/decorators/internal/get_encryption_password.js.map +1 -0
- package/lib/decorators/internal/get_path_and_querify.js +21 -3
- package/lib/decorators/internal/get_path_and_querify.js.map +1 -1
- package/lib/decorators/internal/get_text_body.js +9 -3
- package/lib/decorators/internal/get_text_body.js.map +1 -1
- package/lib/decorators/internal/group_entries.d.ts +1 -0
- package/lib/decorators/internal/group_entries.js +19 -0
- package/lib/decorators/internal/group_entries.js.map +1 -0
- package/lib/decorators/internal/validate_request_form_data.js +3 -1
- package/lib/decorators/internal/validate_request_form_data.js.map +1 -1
- package/lib/decorators/internal/validate_request_headers.js +2 -1
- package/lib/decorators/internal/validate_request_headers.js.map +1 -1
- package/lib/decorators/internal/validate_request_query.js +3 -1
- package/lib/decorators/internal/validate_request_query.js.map +1 -1
- package/lib/options/IResponseBodyQuerifier.d.ts +5 -1
- package/lib/utils/ExceptionManager.d.ts +3 -4
- package/lib/utils/ExceptionManager.js +10 -6
- package/lib/utils/ExceptionManager.js.map +1 -1
- package/lib/utils/SourceFinder.js +26 -3
- package/lib/utils/SourceFinder.js.map +1 -1
- package/lib/utils/VersioningStrategy.d.ts +9 -1
- package/lib/utils/VersioningStrategy.js +18 -6
- package/lib/utils/VersioningStrategy.js.map +1 -1
- package/native/go.mod +1 -1
- package/native/go.sum +2 -2
- package/native/transform/ast.go +4 -2
- package/native/transform/core_querify.go +26 -6
- package/native/transform/core_transform.go +76 -1
- package/native/transform/core_websocket.go +94 -13
- package/native/transform/node_transform.go +6 -0
- package/native/transform/transform.go +8 -18
- package/native/transform/typia_fast.go +6 -0
- package/native/transform.cjs +49 -0
- package/package.json +12 -15
- package/src/adaptors/McpAdaptor.ts +150 -28
- package/src/adaptors/WebSocketAdaptor.ts +136 -38
- package/src/adaptors/internal/RoutePathMatcher.ts +91 -0
- package/src/adaptors/internal/external_context.ts +50 -0
- package/src/decorators/EncryptedBody.ts +2 -2
- package/src/decorators/EncryptedController.ts +6 -2
- package/src/decorators/EncryptedModule.ts +71 -15
- package/src/decorators/EncryptedRoute.ts +7 -5
- package/src/decorators/TypedBody.ts +1 -1
- package/src/decorators/TypedFormData.ts +148 -44
- package/src/decorators/TypedParam.ts +3 -3
- package/src/decorators/TypedQuery.ts +19 -7
- package/src/decorators/internal/EncryptedConstant.ts +14 -1
- package/src/decorators/internal/get_encryption_password.ts +19 -0
- package/src/decorators/internal/get_path_and_querify.ts +29 -2
- package/src/decorators/internal/get_text_body.ts +8 -4
- package/src/decorators/internal/group_entries.ts +17 -0
- package/src/decorators/internal/validate_request_form_data.ts +3 -1
- package/src/decorators/internal/validate_request_headers.ts +2 -2
- package/src/decorators/internal/validate_request_query.ts +3 -2
- package/src/options/IResponseBodyQuerifier.ts +6 -1
- package/src/utils/ExceptionManager.ts +10 -6
- package/src/utils/SourceFinder.ts +24 -3
- package/src/utils/VersioningStrategy.ts +21 -7
- package/lib/transform.d.ts +0 -10
- package/lib/transform.js +0 -25
- package/lib/transform.js.map +0 -1
- 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 {
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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
|
-
*
|
|
21
|
-
*
|
|
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
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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 {
|
|
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 =
|
|
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.
|
|
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`,
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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"
|
|
26
|
-
* \@TypedParam("no")
|
|
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
|
|
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(
|
|
154
|
-
|
|
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: (
|
|
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) =>
|
|
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
|
}
|