@leo-h/create-nodejs-app 1.0.44 → 1.0.45
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/package.json +1 -1
- package/templates/fastify/.lintstagedrc.json +1 -1
- package/templates/fastify/package.json +2 -0
- package/templates/fastify/pnpm-lock.yaml +32 -0
- package/templates/fastify/src/core/create-controller-response-schema.ts +12 -3
- package/templates/fastify/src/env.ts +19 -6
- package/templates/fastify/src/http/app.ts +9 -2
- package/templates/fastify/src/http/controllers/hello-multipart.controller.spec.ts +122 -0
- package/templates/fastify/src/http/controllers/hello-multipart.controller.ts +67 -0
- package/templates/fastify/src/http/controllers/hello.controller.ts +3 -3
- package/templates/fastify/src/http/errors.ts +1 -1
- package/templates/fastify/src/http/plugins/error-handler.plugin.ts +44 -8
- package/templates/fastify/src/http/plugins/multipart-form-data.plugin.ts +241 -0
- package/templates/fastify/src/http/plugins/swagger-file-zod-schema-transform.plugin.ts +45 -0
- package/templates/fastify/test/integration/create-safe-form-data.ts +18 -0
- package/templates/fastify/vitest.config.mts +1 -0
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@leo-h/create-nodejs-app",
|
3
|
-
"version": "1.0.
|
3
|
+
"version": "1.0.45",
|
4
4
|
"packageManager": "pnpm@9.15.9",
|
5
5
|
"author": "Leonardo Henrique <leonardo0507.business@gmail.com>",
|
6
6
|
"description": "Create a modern Node.js app with TypeScript using one command.",
|
@@ -18,10 +18,12 @@
|
|
18
18
|
"dependencies": {
|
19
19
|
"@fastify/cookie": "11.0.2",
|
20
20
|
"@fastify/cors": "11.0.1",
|
21
|
+
"@fastify/multipart": "9.0.3",
|
21
22
|
"@fastify/swagger": "9.4.2",
|
22
23
|
"@fastify/swagger-ui": "5.2.2",
|
23
24
|
"dotenv": "16.4.7",
|
24
25
|
"fastify": "5.3.2",
|
26
|
+
"fastify-plugin": "5.0.1",
|
25
27
|
"fastify-type-provider-zod": "4.0.2",
|
26
28
|
"zod": "3.24.2"
|
27
29
|
},
|
@@ -14,6 +14,9 @@ importers:
|
|
14
14
|
'@fastify/cors':
|
15
15
|
specifier: 11.0.1
|
16
16
|
version: 11.0.1
|
17
|
+
'@fastify/multipart':
|
18
|
+
specifier: 9.0.3
|
19
|
+
version: 9.0.3
|
17
20
|
'@fastify/swagger':
|
18
21
|
specifier: 9.4.2
|
19
22
|
version: 9.4.2
|
@@ -26,6 +29,9 @@ importers:
|
|
26
29
|
fastify:
|
27
30
|
specifier: 5.3.2
|
28
31
|
version: 5.3.2
|
32
|
+
fastify-plugin:
|
33
|
+
specifier: 5.0.1
|
34
|
+
version: 5.0.1
|
29
35
|
fastify-type-provider-zod:
|
30
36
|
specifier: 4.0.2
|
31
37
|
version: 4.0.2(fastify@5.3.2)(zod@3.24.2)
|
@@ -292,12 +298,18 @@ packages:
|
|
292
298
|
'@fastify/ajv-compiler@4.0.2':
|
293
299
|
resolution: {integrity: sha512-Rkiu/8wIjpsf46Rr+Fitd3HRP+VsxUFDDeag0hs9L0ksfnwx2g7SPQQTFL0E8Qv+rfXzQOxBJnjUB9ITUDjfWQ==}
|
294
300
|
|
301
|
+
'@fastify/busboy@3.1.1':
|
302
|
+
resolution: {integrity: sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==}
|
303
|
+
|
295
304
|
'@fastify/cookie@11.0.2':
|
296
305
|
resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==}
|
297
306
|
|
298
307
|
'@fastify/cors@11.0.1':
|
299
308
|
resolution: {integrity: sha512-dmZaE7M1f4SM8ZZuk5RhSsDJ+ezTgI7v3HHRj8Ow9CneczsPLZV6+2j2uwdaSLn8zhTv6QV0F4ZRcqdalGx1pQ==}
|
300
309
|
|
310
|
+
'@fastify/deepmerge@2.0.2':
|
311
|
+
resolution: {integrity: sha512-3wuLdX5iiiYeZWP6bQrjqhrcvBIf0NHbQH1Ur1WbHvoiuTYUEItgygea3zs8aHpiitn0lOB8gX20u1qO+FDm7Q==}
|
312
|
+
|
301
313
|
'@fastify/error@4.1.0':
|
302
314
|
resolution: {integrity: sha512-KeFcciOr1eo/YvIXHP65S94jfEEqn1RxTRBT1aJaHxY5FK0/GDXYozsQMMWlZoHgi8i0s+YtrLsgj/JkUUjSkQ==}
|
303
315
|
|
@@ -310,6 +322,9 @@ packages:
|
|
310
322
|
'@fastify/merge-json-schemas@0.2.1':
|
311
323
|
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
|
312
324
|
|
325
|
+
'@fastify/multipart@9.0.3':
|
326
|
+
resolution: {integrity: sha512-pJogxQCrT12/6I5Fh6jr3narwcymA0pv4B0jbC7c6Bl9wnrxomEUnV0d26w6gUls7gSXmhG8JGRMmHFIPsxt1g==}
|
327
|
+
|
313
328
|
'@fastify/proxy-addr@5.0.0':
|
314
329
|
resolution: {integrity: sha512-37qVVA1qZ5sgH7KpHkkC4z9SK6StIsIcOmpjvMPXNb3vx2GQxhZocogVYbr2PbbeLCQxYIPDok307xEvRZOzGA==}
|
315
330
|
|
@@ -1729,6 +1744,9 @@ packages:
|
|
1729
1744
|
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
1730
1745
|
engines: {node: '>=10'}
|
1731
1746
|
|
1747
|
+
secure-json-parse@3.0.2:
|
1748
|
+
resolution: {integrity: sha512-H6nS2o8bWfpFEV6U38sOSjS7bTbdgbCGU9wEM6W14P5H0QOsz94KCusifV44GpHDTu2nqZbuDNhTzu+mjDSw1w==}
|
1749
|
+
|
1732
1750
|
secure-json-parse@4.0.0:
|
1733
1751
|
resolution: {integrity: sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA==}
|
1734
1752
|
|
@@ -2231,6 +2249,8 @@ snapshots:
|
|
2231
2249
|
ajv-formats: 3.0.1(ajv@8.17.1)
|
2232
2250
|
fast-uri: 3.0.6
|
2233
2251
|
|
2252
|
+
'@fastify/busboy@3.1.1': {}
|
2253
|
+
|
2234
2254
|
'@fastify/cookie@11.0.2':
|
2235
2255
|
dependencies:
|
2236
2256
|
cookie: 1.0.2
|
@@ -2241,6 +2261,8 @@ snapshots:
|
|
2241
2261
|
fastify-plugin: 5.0.1
|
2242
2262
|
toad-cache: 3.7.0
|
2243
2263
|
|
2264
|
+
'@fastify/deepmerge@2.0.2': {}
|
2265
|
+
|
2244
2266
|
'@fastify/error@4.1.0': {}
|
2245
2267
|
|
2246
2268
|
'@fastify/fast-json-stringify-compiler@5.0.3':
|
@@ -2253,6 +2275,14 @@ snapshots:
|
|
2253
2275
|
dependencies:
|
2254
2276
|
dequal: 2.0.3
|
2255
2277
|
|
2278
|
+
'@fastify/multipart@9.0.3':
|
2279
|
+
dependencies:
|
2280
|
+
'@fastify/busboy': 3.1.1
|
2281
|
+
'@fastify/deepmerge': 2.0.2
|
2282
|
+
'@fastify/error': 4.1.0
|
2283
|
+
fastify-plugin: 5.0.1
|
2284
|
+
secure-json-parse: 3.0.2
|
2285
|
+
|
2256
2286
|
'@fastify/proxy-addr@5.0.0':
|
2257
2287
|
dependencies:
|
2258
2288
|
'@fastify/forwarded': 3.0.0
|
@@ -3652,6 +3682,8 @@ snapshots:
|
|
3652
3682
|
|
3653
3683
|
safe-stable-stringify@2.5.0: {}
|
3654
3684
|
|
3685
|
+
secure-json-parse@3.0.2: {}
|
3686
|
+
|
3655
3687
|
secure-json-parse@4.0.0: {}
|
3656
3688
|
|
3657
3689
|
seek-bzip@2.0.0:
|
@@ -29,9 +29,18 @@ export function createControllerResponseSchema<
|
|
29
29
|
>
|
30
30
|
: ErrorInstance<ErrorClasses, K>["schema"];
|
31
31
|
} {
|
32
|
-
const errorInstances = errorClasses
|
33
|
-
|
34
|
-
|
32
|
+
const errorInstances = errorClasses
|
33
|
+
.map(ErrorClass => {
|
34
|
+
return new ErrorClass();
|
35
|
+
})
|
36
|
+
.filter((errorInstance, index, array) => {
|
37
|
+
const prevErrorInstances = array.slice(0, index);
|
38
|
+
const errorAlreadyFiltered = prevErrorInstances.some(({ error }) => {
|
39
|
+
return error === errorInstance.error;
|
40
|
+
});
|
41
|
+
|
42
|
+
return !errorAlreadyFiltered;
|
43
|
+
});
|
35
44
|
const statusCodes = [
|
36
45
|
...new Set([
|
37
46
|
...Object.keys(successResponses),
|
@@ -1,5 +1,6 @@
|
|
1
1
|
import packageJson from "@/../package.json";
|
2
2
|
import { config } from "dotenv";
|
3
|
+
import { existsSync, mkdirSync } from "node:fs";
|
3
4
|
import { resolve } from "node:path";
|
4
5
|
import { z } from "zod";
|
5
6
|
|
@@ -21,21 +22,33 @@ if (process.env.npm_lifecycle_event?.includes(":test"))
|
|
21
22
|
if (!process.env.NODE_ENV)
|
22
23
|
throw new Error("Could not set to the environment variables to use.");
|
23
24
|
|
24
|
-
const
|
25
|
-
|
25
|
+
const nodeEnv = process.env.NODE_ENV as keyof typeof envFileNames;
|
26
|
+
const envFileName = envFileNames[nodeEnv];
|
26
27
|
|
27
28
|
config({
|
28
|
-
path: resolve(
|
29
|
+
path: resolve(
|
30
|
+
__dirname,
|
31
|
+
nodeEnv === "production" ? "../../" : "..",
|
32
|
+
envFileName,
|
33
|
+
),
|
29
34
|
override: true,
|
30
35
|
});
|
31
36
|
|
32
37
|
const schema = z.object({
|
33
|
-
NODE_ENV: z
|
34
|
-
.enum(["production", "development", "test"])
|
35
|
-
.default(process.env.NODE_ENV as keyof typeof envFileNames),
|
38
|
+
NODE_ENV: z.enum(["production", "development", "test"]).default(nodeEnv),
|
36
39
|
API_NAME: z.string().default(packageJson.name),
|
37
40
|
API_PORT: z.coerce.number().default(3333),
|
38
41
|
API_ACCESS_PERMISSION_CLIENT_SIDE: z.string().default("*"),
|
42
|
+
TMP_FILES_PATH: z
|
43
|
+
.string()
|
44
|
+
.default("./tmp")
|
45
|
+
.transform(pathFromSrc => {
|
46
|
+
const tmpPath = resolve(__dirname, "..", pathFromSrc);
|
47
|
+
|
48
|
+
if (!existsSync(tmpPath)) mkdirSync(tmpPath);
|
49
|
+
|
50
|
+
return tmpPath;
|
51
|
+
}),
|
39
52
|
});
|
40
53
|
|
41
54
|
const parsedEnv = schema.safeParse(process.env);
|
@@ -12,8 +12,9 @@ import {
|
|
12
12
|
import { env } from "../env";
|
13
13
|
import { errorHandlerPlugin } from "./plugins/error-handler.plugin";
|
14
14
|
import { notFoundErrorHandlerPlugin } from "./plugins/not-found-error-handler.plugin";
|
15
|
-
import { swaggerUiPlugin } from "./plugins/swagger-ui.plugin";
|
16
15
|
import { routesPlugin } from "./plugins/routes.plugin";
|
16
|
+
import { swaggerFileZodSchemaTransformPlugin } from "./plugins/swagger-file-zod-schema-transform.plugin";
|
17
|
+
import { swaggerUiPlugin } from "./plugins/swagger-ui.plugin";
|
17
18
|
|
18
19
|
export const app = fastify().withTypeProvider<ZodTypeProvider>();
|
19
20
|
export const appPrefix = "/v1";
|
@@ -31,7 +32,13 @@ app.register(fastifySwagger, {
|
|
31
32
|
},
|
32
33
|
servers: [],
|
33
34
|
},
|
34
|
-
transform:
|
35
|
+
transform: data => {
|
36
|
+
const jsonSchema = jsonSchemaTransform(data);
|
37
|
+
|
38
|
+
swaggerFileZodSchemaTransformPlugin(data, jsonSchema);
|
39
|
+
|
40
|
+
return jsonSchema;
|
41
|
+
},
|
35
42
|
});
|
36
43
|
app.register(swaggerUiPlugin);
|
37
44
|
app.register(routesPlugin, { prefix: appPrefix });
|
@@ -0,0 +1,122 @@
|
|
1
|
+
import { env } from "@/env";
|
2
|
+
import { faker } from "@faker-js/faker";
|
3
|
+
import {
|
4
|
+
createSafeFormData,
|
5
|
+
CreateSafeFormDataOutput,
|
6
|
+
} from "test/integration/create-safe-form-data";
|
7
|
+
import { createTestRequest } from "test/integration/create-test-request";
|
8
|
+
import { OverrideProperties } from "type-fest";
|
9
|
+
import { beforeEach, describe, expect, test } from "vitest";
|
10
|
+
import { appPrefix } from "../app";
|
11
|
+
import { ValidationError } from "../errors";
|
12
|
+
import {
|
13
|
+
HelloMultipartControllerBodyInput,
|
14
|
+
helloMultipartControllerMethod,
|
15
|
+
helloMultipartControllerUrl,
|
16
|
+
} from "./hello-multipart.controller";
|
17
|
+
|
18
|
+
const sutMethod = helloMultipartControllerMethod;
|
19
|
+
const sutUrl = appPrefix + helloMultipartControllerUrl;
|
20
|
+
|
21
|
+
type FormDataInput = OverrideProperties<
|
22
|
+
HelloMultipartControllerBodyInput,
|
23
|
+
{
|
24
|
+
attachment: Blob;
|
25
|
+
}
|
26
|
+
>;
|
27
|
+
|
28
|
+
const createFormData = createSafeFormData<FormDataInput>;
|
29
|
+
|
30
|
+
const sut = createTestRequest<{
|
31
|
+
body: FormData;
|
32
|
+
}>({
|
33
|
+
method: sutMethod,
|
34
|
+
url: sutUrl,
|
35
|
+
});
|
36
|
+
|
37
|
+
describe(`[Controller] ${sutMethod} ${sutUrl}`, () => {
|
38
|
+
let defaultFormDataInput: CreateSafeFormDataOutput<FormDataInput>;
|
39
|
+
|
40
|
+
beforeEach(() => {
|
41
|
+
const randomText = faker.lorem.sentences(2);
|
42
|
+
const randomTextBlob = new Blob([randomText], { type: "text/plain" });
|
43
|
+
|
44
|
+
defaultFormDataInput = createFormData({
|
45
|
+
show: "true",
|
46
|
+
attachment: randomTextBlob,
|
47
|
+
});
|
48
|
+
});
|
49
|
+
|
50
|
+
describe("should not be able to get hello world with multipart/form-data content-type", () => {
|
51
|
+
describe("if invalid input", () => {
|
52
|
+
const error = new ValidationError(null);
|
53
|
+
|
54
|
+
test("with invalid property show", async () => {
|
55
|
+
const formDataInput = createFormData({
|
56
|
+
...defaultFormDataInput.input,
|
57
|
+
// @ts-expect-error the value must be a string
|
58
|
+
show: 0,
|
59
|
+
});
|
60
|
+
|
61
|
+
const result = await sut({
|
62
|
+
body: formDataInput.formData,
|
63
|
+
});
|
64
|
+
|
65
|
+
expect(result.statusCode).toEqual(error.statusCode);
|
66
|
+
expect(result.body).toMatchObject({
|
67
|
+
...error.serialize(),
|
68
|
+
debug: [
|
69
|
+
expect.objectContaining({
|
70
|
+
instancePath: "/show",
|
71
|
+
}),
|
72
|
+
],
|
73
|
+
});
|
74
|
+
});
|
75
|
+
});
|
76
|
+
|
77
|
+
test("if property show is set to false", async () => {
|
78
|
+
const formDataInput = createFormData({
|
79
|
+
...defaultFormDataInput.input,
|
80
|
+
show: "false",
|
81
|
+
});
|
82
|
+
|
83
|
+
const result = await sut({
|
84
|
+
body: formDataInput.formData,
|
85
|
+
});
|
86
|
+
|
87
|
+
const error = new ValidationError(
|
88
|
+
`Você não quer exibir o "Hello world" :(`,
|
89
|
+
);
|
90
|
+
|
91
|
+
expect(result.statusCode).toEqual(error.statusCode);
|
92
|
+
expect(result.body).toStrictEqual(error.serialize());
|
93
|
+
});
|
94
|
+
});
|
95
|
+
|
96
|
+
describe("should be able to get hello world with multipart/form-data content-type", async () => {
|
97
|
+
test("if property show is set to true", async () => {
|
98
|
+
const formDataInput = createFormData({
|
99
|
+
...defaultFormDataInput.input,
|
100
|
+
show: "true",
|
101
|
+
});
|
102
|
+
|
103
|
+
const result = await sut({
|
104
|
+
body: formDataInput.formData,
|
105
|
+
});
|
106
|
+
|
107
|
+
expect(result.statusCode).toEqual(200);
|
108
|
+
expect(result.body).toStrictEqual({
|
109
|
+
message: "Hello world!",
|
110
|
+
attachment: {
|
111
|
+
type: "file",
|
112
|
+
encoding: "7bit",
|
113
|
+
mimetype: "text/plain",
|
114
|
+
fileStream: expect.any(Object),
|
115
|
+
fileOriginalName: "blob",
|
116
|
+
fileName: expect.any(String),
|
117
|
+
filePath: expect.stringContaining(env.TMP_FILES_PATH),
|
118
|
+
},
|
119
|
+
});
|
120
|
+
});
|
121
|
+
});
|
122
|
+
});
|
@@ -0,0 +1,67 @@
|
|
1
|
+
import { FastifyZodInstance } from "@/@types/fastify";
|
2
|
+
import { createControllerResponseSchema } from "@/core/create-controller-response-schema";
|
3
|
+
import { z } from "zod";
|
4
|
+
import { InternalServerError, ValidationError } from "../errors";
|
5
|
+
import {
|
6
|
+
MultipartFormDataDiskFile,
|
7
|
+
multipartFormDataPlugin,
|
8
|
+
} from "../plugins/multipart-form-data.plugin";
|
9
|
+
|
10
|
+
export const helloMultipartControllerMethod = "POST" as const;
|
11
|
+
export const helloMultipartControllerUrl = "/hello/multipart" as const;
|
12
|
+
|
13
|
+
export type HelloMultipartControllerBodyInput = z.input<
|
14
|
+
typeof helloMultipartControllerBodySchema
|
15
|
+
>;
|
16
|
+
|
17
|
+
const helloMultipartControllerBodySchema = z.object({
|
18
|
+
show: z
|
19
|
+
.enum(["true", "false"])
|
20
|
+
.transform<boolean>(val => JSON.parse(val))
|
21
|
+
.default("true"),
|
22
|
+
attachment: MultipartFormDataDiskFile.schema,
|
23
|
+
});
|
24
|
+
|
25
|
+
export default function helloMultipartController(app: FastifyZodInstance) {
|
26
|
+
app.register(multipartFormDataPlugin.plugin, {
|
27
|
+
attachments: {
|
28
|
+
storage: "disk",
|
29
|
+
allowedMimeTypes: ["text/plain"],
|
30
|
+
maxCount: 1,
|
31
|
+
maxSize: 1024 * 1024 * 50, // 50MB
|
32
|
+
},
|
33
|
+
});
|
34
|
+
|
35
|
+
app.route({
|
36
|
+
method: helloMultipartControllerMethod,
|
37
|
+
url: helloMultipartControllerUrl,
|
38
|
+
schema: {
|
39
|
+
operationId: "helloMultipartController",
|
40
|
+
tags: ["Hello"],
|
41
|
+
summary: "Hello world!",
|
42
|
+
consumes: ["multipart/form-data"],
|
43
|
+
body: helloMultipartControllerBodySchema,
|
44
|
+
response: createControllerResponseSchema(
|
45
|
+
{
|
46
|
+
200: z.object({
|
47
|
+
message: z.literal("Hello world!"),
|
48
|
+
attachment: z.custom(),
|
49
|
+
}),
|
50
|
+
},
|
51
|
+
InternalServerError,
|
52
|
+
ValidationError,
|
53
|
+
),
|
54
|
+
},
|
55
|
+
handler: async (request, response) => {
|
56
|
+
const { show, attachment } = request.body;
|
57
|
+
|
58
|
+
if (!show)
|
59
|
+
return new ValidationError(`Você não quer exibir o "Hello world" :(`);
|
60
|
+
|
61
|
+
response.status(200).send({
|
62
|
+
message: "Hello world!",
|
63
|
+
attachment: attachment,
|
64
|
+
});
|
65
|
+
},
|
66
|
+
});
|
67
|
+
}
|
@@ -7,10 +7,10 @@ export const helloControllerMethod = "GET" as const;
|
|
7
7
|
export const helloControllerUrl = "/hello" as const;
|
8
8
|
|
9
9
|
export type HelloControllerQueryParamsInput = z.input<
|
10
|
-
typeof
|
10
|
+
typeof helloControllerQueryParamsSchema
|
11
11
|
>;
|
12
12
|
|
13
|
-
const
|
13
|
+
const helloControllerQueryParamsSchema = z.object({
|
14
14
|
show: z
|
15
15
|
.enum(["true", "false"])
|
16
16
|
.transform<boolean>(val => JSON.parse(val))
|
@@ -25,7 +25,7 @@ export default function helloController(app: FastifyZodInstance) {
|
|
25
25
|
operationId: "helloController",
|
26
26
|
tags: ["Hello"],
|
27
27
|
summary: "Hello world!",
|
28
|
-
querystring:
|
28
|
+
querystring: helloControllerQueryParamsSchema,
|
29
29
|
response: createControllerResponseSchema(
|
30
30
|
{
|
31
31
|
200: z.object({
|
@@ -35,7 +35,7 @@ export class InternalServerError extends BaseError {
|
|
35
35
|
public readonly error = "INTERNAL_SERVER_ERROR";
|
36
36
|
public readonly statusCode = 500;
|
37
37
|
public readonly message =
|
38
|
-
"Desculpe, um erro inesperado ocorreu. Tente novamente alguns minutos ou nos contate.";
|
38
|
+
"Desculpe, um erro inesperado ocorreu. Tente novamente em alguns minutos ou nos contate.";
|
39
39
|
|
40
40
|
public constructor(public debug: unknown) {
|
41
41
|
super();
|
@@ -1,6 +1,8 @@
|
|
1
1
|
import { FastifyZodReply, FastifyZodRequest } from "@/@types/fastify";
|
2
|
+
import { FastifyError } from "fastify";
|
2
3
|
import { hasZodFastifySchemaValidationErrors } from "fastify-type-provider-zod";
|
3
4
|
import { BaseError, InternalServerError, ValidationError } from "../errors";
|
5
|
+
import { multipartFormDataPluginErrorCodes } from "./multipart-form-data.plugin";
|
4
6
|
|
5
7
|
export function errorHandlerPlugin(
|
6
8
|
error: unknown,
|
@@ -9,14 +11,48 @@ export function errorHandlerPlugin(
|
|
9
11
|
) {
|
10
12
|
let httpError: BaseError;
|
11
13
|
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
14
|
+
switch (true) {
|
15
|
+
case error instanceof BaseError: {
|
16
|
+
httpError = error;
|
17
|
+
break;
|
18
|
+
}
|
19
|
+
|
20
|
+
case error instanceof SyntaxError: {
|
21
|
+
httpError = new ValidationError(error.message);
|
22
|
+
break;
|
23
|
+
}
|
24
|
+
|
25
|
+
case hasZodFastifySchemaValidationErrors(error): {
|
26
|
+
httpError = new ValidationError(error.validation);
|
27
|
+
break;
|
28
|
+
}
|
29
|
+
|
30
|
+
case error instanceof Error && error.name === "FastifyError": {
|
31
|
+
const fastifyError = error as FastifyError;
|
32
|
+
|
33
|
+
if (fastifyError.code.startsWith("FST_ERR_CTP")) {
|
34
|
+
httpError = new ValidationError(error);
|
35
|
+
break;
|
36
|
+
}
|
37
|
+
|
38
|
+
const multipartErrorCodes =
|
39
|
+
multipartFormDataPluginErrorCodes as unknown as string[];
|
40
|
+
|
41
|
+
if (multipartErrorCodes.includes(fastifyError.code)) {
|
42
|
+
if ("part" in error) delete error.part;
|
43
|
+
|
44
|
+
httpError = new ValidationError(error);
|
45
|
+
break;
|
46
|
+
}
|
47
|
+
|
48
|
+
httpError = new InternalServerError(error);
|
49
|
+
break;
|
50
|
+
}
|
51
|
+
|
52
|
+
default: {
|
53
|
+
httpError = new InternalServerError(error);
|
54
|
+
break;
|
55
|
+
}
|
20
56
|
}
|
21
57
|
|
22
58
|
const isCriticalError = httpError.statusCode.toString().startsWith("5");
|
@@ -0,0 +1,241 @@
|
|
1
|
+
import { FastifyZodInstance } from "@/@types/fastify";
|
2
|
+
import { env } from "@/env";
|
3
|
+
import fastifyMultipart, { MultipartFile } from "@fastify/multipart";
|
4
|
+
import { errorCodes } from "fastify";
|
5
|
+
import fastifyPlugin from "fastify-plugin";
|
6
|
+
import { randomUUID } from "node:crypto";
|
7
|
+
import { createWriteStream } from "node:fs";
|
8
|
+
import { rm } from "node:fs/promises";
|
9
|
+
import { basename, extname, resolve } from "node:path";
|
10
|
+
import { pipeline } from "node:stream/promises";
|
11
|
+
import { z } from "zod";
|
12
|
+
import { ValidationError } from "../errors";
|
13
|
+
|
14
|
+
type InMemoryAttachmentStorage = {
|
15
|
+
/**
|
16
|
+
* File storage strategy.
|
17
|
+
*
|
18
|
+
* @default "disk"
|
19
|
+
*/
|
20
|
+
storage?: "in-memory";
|
21
|
+
};
|
22
|
+
|
23
|
+
type DiskAttachmentStorage = {
|
24
|
+
storage?: "disk";
|
25
|
+
/**
|
26
|
+
* File storage directory.
|
27
|
+
*
|
28
|
+
* @default env.TMP_FILES_PATH
|
29
|
+
*/
|
30
|
+
destinationPath?: string;
|
31
|
+
/**
|
32
|
+
* Remove files after route handler execution
|
33
|
+
*
|
34
|
+
* @default true
|
35
|
+
*/
|
36
|
+
removeAfterHandlerExecution?: boolean;
|
37
|
+
};
|
38
|
+
|
39
|
+
type AttachmentsStorage = InMemoryAttachmentStorage | DiskAttachmentStorage;
|
40
|
+
|
41
|
+
type MultipartFormDataPluginOptions = {
|
42
|
+
attachments?: AttachmentsStorage & {
|
43
|
+
/**
|
44
|
+
* Allowed mime types of files.
|
45
|
+
*
|
46
|
+
* @example ["image/webp", "image/jpeg"]
|
47
|
+
*
|
48
|
+
* @default []
|
49
|
+
*/
|
50
|
+
allowedMimeTypes?: string[];
|
51
|
+
/**
|
52
|
+
* Maximum number of files allowed.
|
53
|
+
*
|
54
|
+
* @default 1
|
55
|
+
*/
|
56
|
+
maxCount?: number;
|
57
|
+
/**
|
58
|
+
* Maximum size allowed for each file in bytes.
|
59
|
+
*
|
60
|
+
* @default 1024 * 1024 * 10 // 10MB
|
61
|
+
*/
|
62
|
+
maxSize?: number;
|
63
|
+
};
|
64
|
+
};
|
65
|
+
|
66
|
+
const plugin = fastifyPlugin(
|
67
|
+
(app: FastifyZodInstance, _options: MultipartFormDataPluginOptions, done) => {
|
68
|
+
const options = {
|
69
|
+
..._options,
|
70
|
+
attachments: {
|
71
|
+
storage: "disk",
|
72
|
+
destinationPath: env.TMP_FILES_PATH,
|
73
|
+
removeAfterHandlerExecution: true,
|
74
|
+
allowedMimeTypes: [],
|
75
|
+
maxCount: 1,
|
76
|
+
maxSize: 1024 * 1024 * 10, // 10MB
|
77
|
+
..._options.attachments,
|
78
|
+
},
|
79
|
+
} satisfies MultipartFormDataPluginOptions;
|
80
|
+
|
81
|
+
app.addHook("onRequest", async request => {
|
82
|
+
const contentType = request.headers["content-type"];
|
83
|
+
const { FST_ERR_CTP_INVALID_MEDIA_TYPE } = errorCodes;
|
84
|
+
|
85
|
+
if (!contentType?.startsWith("multipart/form-data")) {
|
86
|
+
throw new ValidationError(FST_ERR_CTP_INVALID_MEDIA_TYPE(contentType));
|
87
|
+
}
|
88
|
+
});
|
89
|
+
|
90
|
+
app.register(fastifyMultipart, {
|
91
|
+
attachFieldsToBody: "keyValues",
|
92
|
+
limits: {
|
93
|
+
fieldNameSize: 100,
|
94
|
+
fieldSize: 1024 * 1024, // 1MB
|
95
|
+
fields: Infinity,
|
96
|
+
parts: Infinity,
|
97
|
+
headerPairs: 2000,
|
98
|
+
files: options.attachments.maxCount,
|
99
|
+
fileSize: options.attachments.maxSize,
|
100
|
+
},
|
101
|
+
async onFile(file) {
|
102
|
+
const allowedMimeTypes = options.attachments
|
103
|
+
.allowedMimeTypes as string[];
|
104
|
+
|
105
|
+
if (
|
106
|
+
!allowedMimeTypes.includes(file.mimetype) &&
|
107
|
+
!allowedMimeTypes.length
|
108
|
+
) {
|
109
|
+
throw new ValidationError(
|
110
|
+
`Nenhum MIME type de arquivo foi configurado para ser aceito.`,
|
111
|
+
);
|
112
|
+
}
|
113
|
+
|
114
|
+
if (
|
115
|
+
!allowedMimeTypes.includes(file.mimetype) &&
|
116
|
+
allowedMimeTypes.length
|
117
|
+
) {
|
118
|
+
throw new ValidationError(
|
119
|
+
`Apenas os seguintes MIME types são aceitos: ${allowedMimeTypes.join(", ")}.`,
|
120
|
+
);
|
121
|
+
}
|
122
|
+
|
123
|
+
if (options.attachments.storage === "in-memory") {
|
124
|
+
const buffer = await file.toBuffer();
|
125
|
+
|
126
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
127
|
+
(file as any).value = new MultipartFormDataInMemoryFile(file, buffer);
|
128
|
+
return;
|
129
|
+
}
|
130
|
+
|
131
|
+
const uniqueFileName = randomUUID() + extname(file.filename);
|
132
|
+
const filePath = resolve(env.TMP_FILES_PATH, uniqueFileName);
|
133
|
+
|
134
|
+
await pipeline(file.file, createWriteStream(filePath));
|
135
|
+
|
136
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
137
|
+
(file as any).value = new MultipartFormDataDiskFile(file, filePath);
|
138
|
+
},
|
139
|
+
});
|
140
|
+
|
141
|
+
if (
|
142
|
+
options.attachments.storage === "disk" &&
|
143
|
+
options.attachments.removeAfterHandlerExecution
|
144
|
+
) {
|
145
|
+
app.addHook("onResponse", async request => {
|
146
|
+
if (!request.body || typeof request.body !== "object") return;
|
147
|
+
|
148
|
+
const bodyDiskFiles = Object.values(request.body)
|
149
|
+
.filter(value => {
|
150
|
+
const valueIsDiskFile = value instanceof MultipartFormDataDiskFile;
|
151
|
+
const valueIsArrayOfDiskFile =
|
152
|
+
Array.isArray(value) &&
|
153
|
+
value.some(item => item instanceof MultipartFormDataDiskFile);
|
154
|
+
|
155
|
+
return valueIsDiskFile || valueIsArrayOfDiskFile;
|
156
|
+
})
|
157
|
+
.flat() as MultipartFormDataDiskFile[];
|
158
|
+
|
159
|
+
if (!bodyDiskFiles.length) return;
|
160
|
+
|
161
|
+
await Promise.all(
|
162
|
+
bodyDiskFiles.map(diskFile => rm(diskFile.filePath, { force: true })),
|
163
|
+
);
|
164
|
+
});
|
165
|
+
}
|
166
|
+
|
167
|
+
done();
|
168
|
+
},
|
169
|
+
);
|
170
|
+
|
171
|
+
export const multipartFormDataPlugin = Object.freeze({
|
172
|
+
plugin,
|
173
|
+
} as const);
|
174
|
+
|
175
|
+
export const multipartFormDataPluginErrorCodes = [
|
176
|
+
"FST_PARTS_LIMIT",
|
177
|
+
"FST_FILES_LIMIT",
|
178
|
+
"FST_FIELDS_LIMIT",
|
179
|
+
"FST_REQ_FILE_TOO_LARGE",
|
180
|
+
"FST_PROTO_VIOLATION",
|
181
|
+
"FST_INVALID_MULTIPART_CONTENT_TYPE",
|
182
|
+
"FST_INVALID_JSON_FIELD_ERROR",
|
183
|
+
"FST_FILE_BUFFER_NOT_FOUND",
|
184
|
+
"FST_NO_FORM_DATA",
|
185
|
+
] as const;
|
186
|
+
|
187
|
+
export const MULTIPART_FORM_DATA_FILE_SCHEMA_DESCRIPTION =
|
188
|
+
"MULTIPART_FORM_DATA_ATTACHMENT";
|
189
|
+
|
190
|
+
export class MultipartFormDataFile {
|
191
|
+
public type: MultipartFile["type"];
|
192
|
+
public encoding: MultipartFile["encoding"];
|
193
|
+
public mimetype: MultipartFile["mimetype"];
|
194
|
+
public fileStream: MultipartFile["file"];
|
195
|
+
public fileOriginalName: MultipartFile["filename"];
|
196
|
+
|
197
|
+
public constructor(multipartFile: MultipartFile) {
|
198
|
+
this.type = multipartFile.type;
|
199
|
+
this.encoding = multipartFile.encoding;
|
200
|
+
this.mimetype = multipartFile.mimetype;
|
201
|
+
this.fileStream = multipartFile.file;
|
202
|
+
this.fileOriginalName = multipartFile.filename;
|
203
|
+
}
|
204
|
+
}
|
205
|
+
|
206
|
+
export class MultipartFormDataDiskFile extends MultipartFormDataFile {
|
207
|
+
public fileName: string;
|
208
|
+
public filePath: string;
|
209
|
+
|
210
|
+
public constructor(multipartFile: MultipartFile, filePath: string) {
|
211
|
+
super(multipartFile);
|
212
|
+
|
213
|
+
this.fileName = basename(filePath);
|
214
|
+
this.filePath = filePath;
|
215
|
+
}
|
216
|
+
|
217
|
+
public static get schema() {
|
218
|
+
return z
|
219
|
+
.instanceof(MultipartFormDataDiskFile)
|
220
|
+
.describe(MULTIPART_FORM_DATA_FILE_SCHEMA_DESCRIPTION);
|
221
|
+
}
|
222
|
+
}
|
223
|
+
|
224
|
+
export class MultipartFormDataInMemoryFile extends MultipartFormDataFile {
|
225
|
+
public buffer: Buffer<ArrayBufferLike>;
|
226
|
+
|
227
|
+
public constructor(
|
228
|
+
multipartFile: MultipartFile,
|
229
|
+
buffer: Buffer<ArrayBufferLike>,
|
230
|
+
) {
|
231
|
+
super(multipartFile);
|
232
|
+
|
233
|
+
this.buffer = buffer;
|
234
|
+
}
|
235
|
+
|
236
|
+
public static get schema() {
|
237
|
+
return z
|
238
|
+
.instanceof(MultipartFormDataInMemoryFile)
|
239
|
+
.describe(MULTIPART_FORM_DATA_FILE_SCHEMA_DESCRIPTION);
|
240
|
+
}
|
241
|
+
}
|
@@ -0,0 +1,45 @@
|
|
1
|
+
import { FastifyDynamicSwaggerOptions } from "@fastify/swagger";
|
2
|
+
import { jsonSchemaTransform } from "fastify-type-provider-zod";
|
3
|
+
import { ZodArray, ZodObject } from "zod";
|
4
|
+
import { MULTIPART_FORM_DATA_FILE_SCHEMA_DESCRIPTION } from "./multipart-form-data.plugin";
|
5
|
+
|
6
|
+
type RouteData = Parameters<
|
7
|
+
NonNullable<FastifyDynamicSwaggerOptions["transform"]>
|
8
|
+
>[0];
|
9
|
+
|
10
|
+
type JsonSchema = ReturnType<typeof jsonSchemaTransform>;
|
11
|
+
|
12
|
+
export function swaggerFileZodSchemaTransformPlugin(
|
13
|
+
routeData: RouteData,
|
14
|
+
jsonSchema: JsonSchema,
|
15
|
+
) {
|
16
|
+
if (
|
17
|
+
routeData.schema.consumes?.includes("multipart/form-data") &&
|
18
|
+
routeData.schema.body instanceof ZodObject
|
19
|
+
) {
|
20
|
+
for (const bodyFieldName in routeData.schema.body.shape) {
|
21
|
+
const bodyFieldSchema = routeData.schema.body.shape[bodyFieldName];
|
22
|
+
|
23
|
+
if (
|
24
|
+
bodyFieldSchema._def.description ===
|
25
|
+
MULTIPART_FORM_DATA_FILE_SCHEMA_DESCRIPTION
|
26
|
+
) {
|
27
|
+
jsonSchema.schema.body.properties[bodyFieldName] = {
|
28
|
+
type: "string",
|
29
|
+
format: "binary",
|
30
|
+
};
|
31
|
+
}
|
32
|
+
|
33
|
+
if (
|
34
|
+
bodyFieldSchema instanceof ZodArray &&
|
35
|
+
bodyFieldSchema._def.type._def.description ===
|
36
|
+
MULTIPART_FORM_DATA_FILE_SCHEMA_DESCRIPTION
|
37
|
+
) {
|
38
|
+
jsonSchema.schema.body.properties[bodyFieldName] = {
|
39
|
+
type: "array",
|
40
|
+
items: { type: "string", format: "binary" },
|
41
|
+
};
|
42
|
+
}
|
43
|
+
}
|
44
|
+
}
|
45
|
+
}
|
@@ -0,0 +1,18 @@
|
|
1
|
+
type CreateSafeFormDataInput = Record<string, string | Blob>;
|
2
|
+
|
3
|
+
export type CreateSafeFormDataOutput<Input extends CreateSafeFormDataInput> =
|
4
|
+
ReturnType<typeof createSafeFormData<Input>>;
|
5
|
+
|
6
|
+
export function createSafeFormData<Input extends CreateSafeFormDataInput>(
|
7
|
+
input: Input,
|
8
|
+
) {
|
9
|
+
const formData = new FormData();
|
10
|
+
|
11
|
+
for (const inputProperty in input)
|
12
|
+
formData.set(inputProperty, input[inputProperty]);
|
13
|
+
|
14
|
+
return {
|
15
|
+
input,
|
16
|
+
formData,
|
17
|
+
};
|
18
|
+
}
|