@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leo-h/create-nodejs-app",
3
- "version": "1.0.44",
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.",
@@ -1,4 +1,4 @@
1
1
  {
2
2
  "*.{js,ts,json,yaml,yml,md}": "prettier --write --cache",
3
- "*.{js,ts}": ["eslint --max-warnings 0 --fix --cache"]
3
+ "*.{js,ts}": ["eslint --max-warnings 0 --fix --cache --no-ignore"]
4
4
  }
@@ -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.map(ErrorClass => {
33
- return new ErrorClass();
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 envFileName =
25
- envFileNames[process.env.NODE_ENV as keyof typeof envFileNames];
25
+ const nodeEnv = process.env.NODE_ENV as keyof typeof envFileNames;
26
+ const envFileName = envFileNames[nodeEnv];
26
27
 
27
28
  config({
28
- path: resolve(__dirname, "..", envFileName),
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: jsonSchemaTransform,
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 helloControllerQueryParamsInputSchema
10
+ typeof helloControllerQueryParamsSchema
11
11
  >;
12
12
 
13
- const helloControllerQueryParamsInputSchema = z.object({
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: helloControllerQueryParamsInputSchema,
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
- if (error instanceof BaseError) {
13
- httpError = error;
14
- } else if (error instanceof SyntaxError) {
15
- httpError = new ValidationError(error.message);
16
- } else if (hasZodFastifySchemaValidationErrors(error)) {
17
- httpError = new ValidationError(error.validation);
18
- } else {
19
- httpError = new InternalServerError(error);
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
+ }
@@ -4,6 +4,7 @@ import { defineConfig } from "vitest/config";
4
4
  export default defineConfig({
5
5
  plugins: [viteTsconfigPaths()],
6
6
  test: {
7
+ reporters: ["verbose"],
7
8
  fakeTimers: {
8
9
  toFake: [
9
10
  "Date",