@leo-h/create-nodejs-app 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/package.json.js +1 -1
  2. package/dist/src/validations/framework.validation.js +1 -1
  3. package/package.json +1 -1
  4. package/templates/nest/.env.example +11 -0
  5. package/templates/nest/.eslintignore +2 -0
  6. package/templates/nest/.eslintrc.json +22 -0
  7. package/templates/nest/.husky/pre-commit +1 -0
  8. package/templates/nest/.lintstagedrc.json +4 -0
  9. package/templates/nest/.prettierignore +7 -0
  10. package/templates/nest/.prettierrc.json +6 -0
  11. package/templates/nest/.swcrc +20 -0
  12. package/templates/nest/.vscode/settings.json +8 -0
  13. package/templates/nest/nest-cli.json +10 -0
  14. package/templates/nest/package.json +59 -0
  15. package/templates/nest/pnpm-lock.yaml +6372 -0
  16. package/templates/nest/src/core/errors/domain-error.ts +8 -0
  17. package/templates/nest/src/core/errors/errors.ts +9 -0
  18. package/templates/nest/src/infra/app.module.ts +7 -0
  19. package/templates/nest/src/infra/env.ts +22 -0
  20. package/templates/nest/src/infra/http/controllers/hello/hello-multipart.controller.e2e-spec.ts +58 -0
  21. package/templates/nest/src/infra/http/controllers/hello/hello-multipart.controller.ts +75 -0
  22. package/templates/nest/src/infra/http/controllers/hello/hello.controller.e2e-spec.ts +38 -0
  23. package/templates/nest/src/infra/http/controllers/hello/hello.controller.ts +51 -0
  24. package/templates/nest/src/infra/http/errors/upload-validation.error.ts +17 -0
  25. package/templates/nest/src/infra/http/events/fastify-multer.event.module.ts +15 -0
  26. package/templates/nest/src/infra/http/filters/domain-exception.filter.ts +53 -0
  27. package/templates/nest/src/infra/http/filters/http-exception.filter.ts +26 -0
  28. package/templates/nest/src/infra/http/http.module.ts +23 -0
  29. package/templates/nest/src/infra/http/middlewares/upload-interceptor.ts +365 -0
  30. package/templates/nest/src/infra/http/middlewares/zod-schema-pipe.ts +78 -0
  31. package/templates/nest/src/infra/http/middlewares/zod-validation-pipe.ts +33 -0
  32. package/templates/nest/src/infra/presenters/error.presenter.ts +14 -0
  33. package/templates/nest/src/infra/server.ts +45 -0
  34. package/templates/nest/test/e2e/sample-upload.jpg +0 -0
  35. package/templates/nest/tsconfig.json +17 -0
  36. package/templates/nest/vitest.config.e2e.mts +8 -0
  37. package/templates/nest/vitest.config.mts +6 -0
@@ -0,0 +1,365 @@
1
+ import {
2
+ CallHandler,
3
+ ExecutionContext,
4
+ NestInterceptor,
5
+ UseInterceptors,
6
+ applyDecorators,
7
+ } from "@nestjs/common";
8
+ import { ApiBody, ApiConsumes } from "@nestjs/swagger";
9
+ import { SchemaObject } from "@nestjs/swagger/dist/interfaces/open-api-spec.interface";
10
+ import { randomUUID } from "crypto";
11
+ import { FastifyReply, FastifyRequest } from "fastify";
12
+ import multer, {
13
+ MulterError,
14
+ diskStorage,
15
+ memoryStorage,
16
+ } from "fastify-multer";
17
+ import { File, FileFilter, StorageEngine } from "fastify-multer/lib/interfaces";
18
+ import { extension } from "mime-types";
19
+ import { extname } from "path";
20
+ import prettyBytes from "pretty-bytes";
21
+ import { ZodObject, ZodRawShape, z } from "zod";
22
+ import { UploadValidationError } from "../errors/upload-validation.error";
23
+ import { zodSchemaToSwaggerSchema } from "./zod-schema-pipe";
24
+
25
+ interface MemoryStorage {
26
+ /**
27
+ * The in-memory storage mechanism stores the files in memory as ```Buffer```.
28
+ *
29
+ * WARNING: Loading very large files or relatively small files in large quantities very quickly may cause your application to run out of memory when memory storage is used.
30
+ */
31
+ storage: "memory";
32
+ }
33
+
34
+ interface DiskStorage {
35
+ /**
36
+ * The disk storage mechanism delegates the file processing to the machine and provides full control of the file on disk.
37
+ *
38
+ * Ideal for most cases and essential for large files.
39
+ */
40
+ storage: "disk";
41
+ /**
42
+ * File storage directory.
43
+ *
44
+ * @default "./tmp"
45
+ */
46
+ destination?: string;
47
+ }
48
+
49
+ type Storage = MemoryStorage | DiskStorage;
50
+
51
+ type UploadInterceptorOptions = Storage & {
52
+ /**
53
+ * Name of the file field that will be read when sent in the request with the multipart/form-data format.
54
+ *
55
+ * @example "attachment"
56
+ */
57
+ fieldName: string;
58
+ /**
59
+ * Allowed file extensions. Pass an empty array or ignore this option to accept all file formats.
60
+ *
61
+ * @default []
62
+ * @example ["jpeg", "png"]
63
+ */
64
+ allowedExtensions?: string[];
65
+ /**
66
+ * Mandatory file upload.
67
+ *
68
+ * If a number is specified, a minimum number of files is required (specify the maximum number with the ```maxFileCount``` option).
69
+ *
70
+ * @default true
71
+ */
72
+ required?: boolean | number;
73
+ /**
74
+ * Maximum number of files allowed for the same field.
75
+ *
76
+ * @default 1
77
+ */
78
+ maxFileCount?: number;
79
+ /**
80
+ * Maximum size allowed for each file in megabytes (MB).
81
+ *
82
+ * @default 50 // 50MB
83
+ * @example
84
+ * 1 / 1000 // 1KB
85
+ * 1 // 1MB
86
+ * 1000 // 1GB
87
+ * 1000 * 100 // 100GB
88
+ * Infinity
89
+ */
90
+ maxFileSize?: number;
91
+ /**
92
+ * A zod schema that matches all non-file fields.
93
+ *
94
+ * Non-file field values are automatically ```JSON.parse()``` whenever possible, allowing you to send structures that are the same as the ```application/json``` format.
95
+ *
96
+ * WARN: The zod schema is only used to be added to Swagger. To validate, use the ```ZodSchemaPipe``` decorator with the ```isMultipart: true``` option.
97
+ */
98
+ nonFileFieldsZodSchema?: ZodObject<ZodRawShape>;
99
+ /**
100
+ * Maximum allowed size for each non-file field values in megabytes (MB).
101
+ *
102
+ * @default 1 // 1MB
103
+ * @example
104
+ * 1 / 1000 // 1KB
105
+ * 1 // 1MB
106
+ * 1000 // 1GB
107
+ * 1000 * 100 // 100GB
108
+ * Infinity
109
+ */
110
+ maxNonFileFieldSize?: number;
111
+ };
112
+
113
+ /**
114
+ * Assumes the request body as multipart/form-data. Useful for file uploads.
115
+ */
116
+ export function UploadInterceptor(options: UploadInterceptorOptions) {
117
+ const {
118
+ fieldName,
119
+ storage,
120
+ destination,
121
+ required,
122
+ maxFileCount,
123
+ maxFileSize,
124
+ nonFileFieldsZodSchema,
125
+ maxNonFileFieldSize,
126
+ ...restOptions
127
+ } = {
128
+ destination: "./tmp",
129
+ allowedExtensions: [],
130
+ required: true,
131
+ maxFileCount: 1,
132
+ maxFileSize: 50,
133
+ nonFileFieldsZodSchema: z.object({}),
134
+ maxNonFileFieldSize: 1,
135
+ ...options,
136
+ };
137
+ const nonFileFieldsSwaggerSchema = zodSchemaToSwaggerSchema(
138
+ nonFileFieldsZodSchema,
139
+ );
140
+
141
+ let allowedExtensions = restOptions.allowedExtensions;
142
+
143
+ allowedExtensions = allowedExtensions.map(ext => ext.replace(".", ""));
144
+
145
+ const getMulterStorage = (): StorageEngine => {
146
+ if (storage === "disk") {
147
+ return diskStorage({
148
+ destination,
149
+ filename: (_req, file, cb) => {
150
+ const ext = extname(file.originalname);
151
+
152
+ cb(null, `${randomUUID()}${ext}`);
153
+ },
154
+ });
155
+ }
156
+
157
+ return memoryStorage();
158
+ };
159
+ const setMulterFileFilter: FileFilter = (
160
+ _req,
161
+ { mimetype, originalname, fieldname },
162
+ cb,
163
+ ) => {
164
+ if (
165
+ !allowedExtensions ||
166
+ (allowedExtensions && allowedExtensions.length < 1)
167
+ ) {
168
+ return cb(null, true);
169
+ }
170
+
171
+ const fileExtension =
172
+ extension(mimetype) || extname(originalname).replace(".", "");
173
+
174
+ if (allowedExtensions.includes(fileExtension)) {
175
+ cb(null, true);
176
+ } else {
177
+ cb(
178
+ new UploadValidationError(400, {
179
+ message: "Invalid file format.",
180
+ fieldName: fieldname,
181
+ allowedExtensions,
182
+ }),
183
+ );
184
+ }
185
+ };
186
+
187
+ const getFileFieldSwaggerSchema = (): SchemaObject => {
188
+ if (maxFileCount > 1)
189
+ return {
190
+ type: "array",
191
+ items: {
192
+ type: "string",
193
+ format: "binary",
194
+ },
195
+ };
196
+
197
+ return {
198
+ type: "string",
199
+ format: "binary",
200
+ };
201
+ };
202
+ const getRequiredFieldsSwaggerSchema = (): SchemaObject["required"] => {
203
+ const fields: string[] = [];
204
+
205
+ if (required) fields.push(fieldName);
206
+
207
+ if (nonFileFieldsSwaggerSchema.required)
208
+ fields.push(...nonFileFieldsSwaggerSchema.required);
209
+
210
+ return fields;
211
+ };
212
+
213
+ const megabytesToBytes = (mb: number) => mb * 1000 * 1000;
214
+
215
+ return applyDecorators(
216
+ UseInterceptors(
217
+ new ExecuteUploadInterceptor({
218
+ multerInstance: multer({
219
+ storage: getMulterStorage(),
220
+ fileFilter: setMulterFileFilter,
221
+ limits: {
222
+ files: maxFileCount,
223
+ fileSize: megabytesToBytes(maxFileSize),
224
+ fields: Object.keys(nonFileFieldsZodSchema.shape).length,
225
+ fieldSize: megabytesToBytes(maxNonFileFieldSize),
226
+ },
227
+ }),
228
+ fieldName,
229
+ required,
230
+ }),
231
+ ),
232
+ ApiConsumes("multipart/form-data"),
233
+ ApiBody({
234
+ schema: {
235
+ type: "object",
236
+ properties: {
237
+ [fieldName]: getFileFieldSwaggerSchema(),
238
+ ...nonFileFieldsSwaggerSchema.properties,
239
+ },
240
+ required: getRequiredFieldsSwaggerSchema(),
241
+ },
242
+ }),
243
+ );
244
+ }
245
+
246
+ interface ExecuteUploadInterceptorParams {
247
+ multerInstance: ReturnType<typeof multer>;
248
+ fieldName: string;
249
+ required: boolean | number;
250
+ }
251
+
252
+ interface FastifyRequestWithFile extends FastifyRequest {
253
+ file?: File;
254
+ files?: File[];
255
+ }
256
+
257
+ export class ExecuteUploadInterceptor implements NestInterceptor {
258
+ public constructor(private params: ExecuteUploadInterceptorParams) {}
259
+
260
+ async intercept(context: ExecutionContext, next: CallHandler) {
261
+ const { multerInstance, fieldName, required } = this.params;
262
+ const limits = multerInstance.limits!;
263
+ const isMultipleUpload = limits.files! > 1;
264
+
265
+ let middleware = multerInstance.single(fieldName);
266
+
267
+ if (isMultipleUpload) middleware = multerInstance.array(fieldName);
268
+
269
+ const ctx = context.switchToHttp();
270
+ const request = ctx.getRequest<FastifyRequestWithFile>();
271
+ const response = ctx.getResponse<FastifyReply>();
272
+
273
+ await new Promise<void>((resolve, reject) => {
274
+ // @ts-expect-error the method is not passed to the fastify pre hook handler
275
+ middleware(request, response, error => {
276
+ if (error && error instanceof MulterError) {
277
+ switch (error.code) {
278
+ case "LIMIT_FILE_COUNT":
279
+ reject(
280
+ new UploadValidationError(413, {
281
+ multerError: error.code,
282
+ message: error.message,
283
+ maxFileCount: limits.files!,
284
+ }),
285
+ );
286
+ break;
287
+
288
+ case "LIMIT_FILE_SIZE":
289
+ reject(
290
+ new UploadValidationError(413, {
291
+ multerError: error.code,
292
+ message: error.message,
293
+ fieldName: error.field,
294
+ maxFileSize: prettyBytes(limits.fileSize!),
295
+ }),
296
+ );
297
+ break;
298
+
299
+ default:
300
+ reject(
301
+ new UploadValidationError(413, {
302
+ multerError: error.code,
303
+ message: error.message,
304
+ fieldName: error.field,
305
+ }),
306
+ );
307
+ break;
308
+ }
309
+ }
310
+
311
+ if (error) return reject(error);
312
+
313
+ resolve();
314
+ });
315
+ });
316
+
317
+ if (required && !isMultipleUpload && !request.file) {
318
+ throw new UploadValidationError(400, {
319
+ multerError: null,
320
+ message: `Field "${fieldName}" is required with a file.`,
321
+ });
322
+ }
323
+
324
+ const minFileCountIsMissing =
325
+ typeof required === "number" &&
326
+ request.files &&
327
+ request.files.length < required;
328
+
329
+ if (
330
+ required &&
331
+ isMultipleUpload &&
332
+ (!request.files || !request.files.length || minFileCountIsMissing)
333
+ ) {
334
+ const minFileCount = typeof required === "number" ? required : 1;
335
+
336
+ throw new UploadValidationError(400, {
337
+ multerError: null,
338
+ message: `Field "${fieldName}" is required with at least ${minFileCount} file(s).`,
339
+ });
340
+ }
341
+
342
+ const parseNonFileFields = () => {
343
+ const body = request.body as Record<string, string>;
344
+
345
+ return Object.keys(body).reduce(
346
+ (obj, key) => {
347
+ const value = body[key];
348
+
349
+ try {
350
+ obj[key] = JSON.parse(value);
351
+ } catch {
352
+ obj[key] = value;
353
+ }
354
+
355
+ return obj;
356
+ },
357
+ {} as typeof body,
358
+ );
359
+ };
360
+
361
+ request.body = parseNonFileFields();
362
+
363
+ return next.handle();
364
+ }
365
+ }
@@ -0,0 +1,78 @@
1
+ import { createZodDto } from "@anatine/zod-nestjs";
2
+ import { extendApi, generateSchema } from "@anatine/zod-openapi";
3
+ import { UsePipes, applyDecorators } from "@nestjs/common";
4
+ import { ZodType } from "zod";
5
+ import {
6
+ ZodValidationPipe,
7
+ ZodValidationPipeSchemas,
8
+ } from "./zod-validation-pipe";
9
+
10
+ import { ApiBody, ApiParam, ApiQuery, ApiResponse } from "@nestjs/swagger";
11
+ import { SchemaObject } from "@nestjs/swagger/dist/interfaces/open-api-spec.interface";
12
+ import { RequireAtLeastOne } from "type-fest";
13
+
14
+ export function zodSchemaToSwaggerSchema(schema: ZodType) {
15
+ return generateSchema(schema, false, "3.0") as SchemaObject;
16
+ }
17
+
18
+ export function zodSchemaToNestDto(schema: ZodType) {
19
+ return class Dto extends createZodDto(extendApi(schema)) {};
20
+ }
21
+
22
+ interface ZodSchemaPipeParams extends ZodValidationPipeSchemas {
23
+ isMultipart?: boolean;
24
+ response?: ZodType | Record<number, ZodType>;
25
+ }
26
+
27
+ type NestSwaggerDecorator =
28
+ | (MethodDecorator & ClassDecorator)
29
+ | MethodDecorator;
30
+
31
+ export function ZodSchemaPipe({
32
+ isMultipart,
33
+ routeParams,
34
+ queryParams,
35
+ body,
36
+ response,
37
+ }: RequireAtLeastOne<ZodSchemaPipeParams>) {
38
+ const apiDecorators: NestSwaggerDecorator[] = [];
39
+
40
+ if (routeParams) {
41
+ apiDecorators.push(
42
+ ApiParam({ type: zodSchemaToNestDto(routeParams), name: "" }),
43
+ );
44
+ }
45
+
46
+ if (queryParams) {
47
+ apiDecorators.push(ApiQuery({ type: zodSchemaToNestDto(queryParams) }));
48
+ }
49
+
50
+ if (body && !isMultipart) {
51
+ apiDecorators.push(ApiBody({ type: zodSchemaToNestDto(body) }));
52
+ }
53
+
54
+ if (response) {
55
+ if (response instanceof ZodType) {
56
+ apiDecorators.push(
57
+ ApiResponse({ schema: zodSchemaToSwaggerSchema(response) }),
58
+ );
59
+ } else {
60
+ for (const statusCode in response) {
61
+ apiDecorators.push(
62
+ ApiResponse({
63
+ schema: zodSchemaToSwaggerSchema(response[statusCode]),
64
+ status: Number(statusCode),
65
+ }),
66
+ );
67
+ }
68
+ }
69
+ }
70
+
71
+ const zodValidationPipe = new ZodValidationPipe({
72
+ routeParams,
73
+ queryParams,
74
+ body,
75
+ });
76
+
77
+ return applyDecorators(UsePipes(zodValidationPipe), ...apiDecorators);
78
+ }
@@ -0,0 +1,33 @@
1
+ import { ValidationError } from "@/core/errors/errors";
2
+ import { ArgumentMetadata, PipeTransform } from "@nestjs/common";
3
+ import { ZodError, ZodType } from "zod";
4
+
5
+ export interface ZodValidationPipeSchemas {
6
+ routeParams?: ZodType;
7
+ queryParams?: ZodType;
8
+ body?: ZodType;
9
+ }
10
+
11
+ export class ZodValidationPipe implements PipeTransform {
12
+ constructor(private schemas: ZodValidationPipeSchemas) {}
13
+
14
+ transform(value: unknown, { type }: ArgumentMetadata) {
15
+ try {
16
+ const mappedSchema = {
17
+ param: this.schemas.routeParams,
18
+ query: this.schemas.queryParams,
19
+ body: this.schemas.body,
20
+ };
21
+ const schema = mappedSchema[type as keyof typeof mappedSchema];
22
+
23
+ if (schema) return schema.parse(value);
24
+
25
+ return value;
26
+ } catch (error) {
27
+ if (error instanceof ZodError)
28
+ throw new ValidationError(error.flatten().fieldErrors);
29
+
30
+ throw new ValidationError();
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,14 @@
1
+ import { DomainError } from "@/core/errors/domain-error";
2
+
3
+ type CustomError = Pick<DomainError, "error" | "message" | "debug">;
4
+
5
+ export class ErrorPresenter {
6
+ public static toHttp(statusCode: number, error: DomainError | CustomError) {
7
+ return {
8
+ error: error.error,
9
+ message: error.message,
10
+ statusCode,
11
+ debug: error.debug,
12
+ };
13
+ }
14
+ }
@@ -0,0 +1,45 @@
1
+ import { NestFactory } from "@nestjs/core";
2
+ import {
3
+ FastifyAdapter,
4
+ NestFastifyApplication,
5
+ } from "@nestjs/platform-fastify";
6
+ import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
7
+ import packageJson from "package.json";
8
+ import { AppModule } from "./app.module";
9
+ import { env } from "./env";
10
+
11
+ (async () => {
12
+ const app = await NestFactory.create<NestFastifyApplication>(
13
+ AppModule,
14
+ new FastifyAdapter(),
15
+ {
16
+ cors: {
17
+ origin: env.API_ACCESS_PERMISSION_CLIENT_SIDE,
18
+ },
19
+ },
20
+ );
21
+
22
+ const swaggerDocumentConfig = new DocumentBuilder()
23
+ .setTitle(env.API_NAME)
24
+ .setDescription("")
25
+ .setVersion(packageJson.version)
26
+ .build();
27
+ const swaggerDocument = SwaggerModule.createDocument(
28
+ app,
29
+ swaggerDocumentConfig,
30
+ );
31
+ const swaggerPath = "docs";
32
+
33
+ SwaggerModule.setup(swaggerPath, app, swaggerDocument, {
34
+ swaggerOptions: {
35
+ defaultModelsExpandDepth: -1,
36
+ },
37
+ });
38
+
39
+ await app.listen(env.API_PORT, "0.0.0.0");
40
+
41
+ console.log(`Application "${env.API_NAME}" is running!`);
42
+
43
+ if (env.NODE_ENV === "development")
44
+ console.log(`http://localhost:${env.API_PORT}/${swaggerPath}`);
45
+ })();
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "esnext",
4
+ "module": "CommonJS",
5
+ "emitDecoratorMetadata": true,
6
+ "experimentalDecorators": true,
7
+ "esModuleInterop": true,
8
+ "resolveJsonModule": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "strict": true,
11
+ "skipLibCheck": true,
12
+ "baseUrl": ".",
13
+ "paths": {
14
+ "@/*": ["./src/*"]
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,8 @@
1
+ import { mergeConfig } from "vitest/config";
2
+ import defaultConfig from "./vitest.config.mjs";
3
+
4
+ export default mergeConfig(defaultConfig, {
5
+ test: {
6
+ include: ["./**/*.e2e-spec.ts"],
7
+ },
8
+ });
@@ -0,0 +1,6 @@
1
+ import swc from "unplugin-swc";
2
+ import { defineConfig } from "vitest/config";
3
+
4
+ export default defineConfig({
5
+ plugins: [swc.vite({ module: { type: "es6" } })],
6
+ });