@leo-h/create-nodejs-app 1.0.44 → 1.0.46

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.46",
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",
@@ -56,7 +56,7 @@
56
56
  "type-fest": "4.38.0",
57
57
  "typescript": "5.8.3",
58
58
  "typescript-eslint": "8.28.0",
59
- "unplugin-swc": "1.5.1",
59
+ "unplugin-swc": "1.5.5",
60
60
  "vitest": "1.6.1"
61
61
  }
62
62
  }
@@ -115,8 +115,8 @@ importers:
115
115
  specifier: 8.28.0
116
116
  version: 8.28.0(eslint@9.23.0)(typescript@5.8.3)
117
117
  unplugin-swc:
118
- specifier: 1.5.1
119
- version: 1.5.1(@swc/core@1.11.13)(rollup@4.19.0)
118
+ specifier: 1.5.5
119
+ version: 1.5.5(@swc/core@1.11.13)(rollup@4.19.0)
120
120
  vitest:
121
121
  specifier: 1.6.1
122
122
  version: 1.6.1(@types/node@22.13.14)(terser@5.31.3)
@@ -606,8 +606,8 @@ packages:
606
606
  resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
607
607
  engines: {node: '>=14'}
608
608
 
609
- '@rollup/pluginutils@5.1.0':
610
- resolution: {integrity: sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==}
609
+ '@rollup/pluginutils@5.2.0':
610
+ resolution: {integrity: sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==}
611
611
  engines: {node: '>=14.0.0'}
612
612
  peerDependencies:
613
613
  rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
@@ -1025,13 +1025,13 @@ packages:
1025
1025
  resolution: {integrity: sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==}
1026
1026
  engines: {node: '>=0.4.0'}
1027
1027
 
1028
- acorn@8.12.1:
1029
- resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==}
1028
+ acorn@8.14.0:
1029
+ resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==}
1030
1030
  engines: {node: '>=0.4.0'}
1031
1031
  hasBin: true
1032
1032
 
1033
- acorn@8.14.0:
1034
- resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==}
1033
+ acorn@8.15.0:
1034
+ resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
1035
1035
  engines: {node: '>=0.4.0'}
1036
1036
  hasBin: true
1037
1037
 
@@ -2495,6 +2495,10 @@ packages:
2495
2495
  resolution: {integrity: sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==}
2496
2496
  engines: {node: '>=12'}
2497
2497
 
2498
+ picomatch@4.0.2:
2499
+ resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
2500
+ engines: {node: '>=12'}
2501
+
2498
2502
  pidtree@0.6.0:
2499
2503
  resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==}
2500
2504
  engines: {node: '>=0.10'}
@@ -3079,14 +3083,14 @@ packages:
3079
3083
  resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
3080
3084
  engines: {node: '>= 0.8'}
3081
3085
 
3082
- unplugin-swc@1.5.1:
3083
- resolution: {integrity: sha512-/ZLrPNjChhGx3Z95pxJ4tQgfI6rWqukgYHKflrNB4zAV1izOQuDhkTn55JWeivpBxDCoK7M/TStb2aS/14PS/g==}
3086
+ unplugin-swc@1.5.5:
3087
+ resolution: {integrity: sha512-BahYtYvQ/KSgOqHoy5FfQgp/oZNAB7jwERxNeFVeN/PtJhg4fpK/ybj9OwKtqGPseOadS7+TGbq6tH2DmDAYvA==}
3084
3088
  peerDependencies:
3085
3089
  '@swc/core': ^1.2.108
3086
3090
 
3087
- unplugin@1.12.0:
3088
- resolution: {integrity: sha512-KeczzHl2sATPQUx1gzo+EnUkmN4VmGBYRRVOZSGvGITE9rGHRDGqft6ONceP3vgXcyJ2XjX5axG5jMWUwNCYLw==}
3089
- engines: {node: '>=14.0.0'}
3091
+ unplugin@2.3.5:
3092
+ resolution: {integrity: sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw==}
3093
+ engines: {node: '>=18.12.0'}
3090
3094
 
3091
3095
  update-browserslist-db@1.1.1:
3092
3096
  resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==}
@@ -3726,11 +3730,11 @@ snapshots:
3726
3730
  '@pkgjs/parseargs@0.11.0':
3727
3731
  optional: true
3728
3732
 
3729
- '@rollup/pluginutils@5.1.0(rollup@4.19.0)':
3733
+ '@rollup/pluginutils@5.2.0(rollup@4.19.0)':
3730
3734
  dependencies:
3731
- '@types/estree': 1.0.5
3735
+ '@types/estree': 1.0.6
3732
3736
  estree-walker: 2.0.2
3733
- picomatch: 2.3.1
3737
+ picomatch: 4.0.2
3734
3738
  optionalDependencies:
3735
3739
  rollup: 4.19.0
3736
3740
 
@@ -4202,10 +4206,10 @@ snapshots:
4202
4206
  dependencies:
4203
4207
  acorn: 8.14.0
4204
4208
 
4205
- acorn@8.12.1: {}
4206
-
4207
4209
  acorn@8.14.0: {}
4208
4210
 
4211
+ acorn@8.15.0: {}
4212
+
4209
4213
  ajv-formats@2.1.1(ajv@8.12.0):
4210
4214
  optionalDependencies:
4211
4215
  ajv: 8.12.0
@@ -5738,6 +5742,8 @@ snapshots:
5738
5742
 
5739
5743
  picomatch@4.0.1: {}
5740
5744
 
5745
+ picomatch@4.0.2: {}
5746
+
5741
5747
  pidtree@0.6.0: {}
5742
5748
 
5743
5749
  pino-abstract-transport@1.2.0:
@@ -6336,20 +6342,19 @@ snapshots:
6336
6342
  unpipe@1.0.0:
6337
6343
  optional: true
6338
6344
 
6339
- unplugin-swc@1.5.1(@swc/core@1.11.13)(rollup@4.19.0):
6345
+ unplugin-swc@1.5.5(@swc/core@1.11.13)(rollup@4.19.0):
6340
6346
  dependencies:
6341
- '@rollup/pluginutils': 5.1.0(rollup@4.19.0)
6347
+ '@rollup/pluginutils': 5.2.0(rollup@4.19.0)
6342
6348
  '@swc/core': 1.11.13
6343
6349
  load-tsconfig: 0.2.5
6344
- unplugin: 1.12.0
6350
+ unplugin: 2.3.5
6345
6351
  transitivePeerDependencies:
6346
6352
  - rollup
6347
6353
 
6348
- unplugin@1.12.0:
6354
+ unplugin@2.3.5:
6349
6355
  dependencies:
6350
- acorn: 8.12.1
6351
- chokidar: 3.6.0
6352
- webpack-sources: 3.2.3
6356
+ acorn: 8.15.0
6357
+ picomatch: 4.0.2
6353
6358
  webpack-virtual-modules: 0.6.2
6354
6359
 
6355
6360
  update-browserslist-db@1.1.1(browserslist@4.24.3):