@tahminator/sapling 2.0.3 → 2.0.5

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/README.md CHANGED
@@ -200,12 +200,71 @@ class UserController {
200
200
  }
201
201
  ```
202
202
 
203
- Make sure to register an error handler middleware:
203
+ Sapling ships with default error middlewares, and you can also write your own.
204
+ Register error middlewares after your regular middlewares and controllers:
204
205
 
205
206
  ```typescript
206
- Sapling.loadResponseStatusErrorMiddleware(app, (err, req, res, next) => {
207
- res.status(err.status).json({ error: err.message });
208
- });
207
+ import {
208
+ DefaultBaseErrorMiddleware,
209
+ DefaultResponseStatusErrorMiddleware,
210
+ } from "@tahminator/sapling";
211
+
212
+ // regular middlewares & controllers first
213
+ const middlewares: Class<any>[] = [CookieParserMiddleware];
214
+ middlewares.map(Sapling.resolve).forEach((r) => app.use(r));
215
+
216
+ const controllers: Class<any>[] = [UserController];
217
+ controllers.map(Sapling.resolve).forEach((r) => app.use(r));
218
+
219
+ // error middlewares last
220
+ const errorMiddlewares: Class<any>[] = [
221
+ DefaultResponseStatusErrorMiddleware,
222
+ DefaultBaseErrorMiddleware,
223
+ ];
224
+ errorMiddlewares.map(Sapling.resolve).forEach((r) => app.use(r));
225
+ ```
226
+
227
+ You can also write your own error middlewares. A specific handler should call
228
+ `next(err)` when it does not handle the error, and a base handler should be last
229
+ and return a response:
230
+
231
+ ```typescript
232
+ @MiddlewareClass()
233
+ class ResponseStatusErrorMiddleware {
234
+ @Middleware()
235
+ handle(
236
+ err: unknown,
237
+ _request: Request,
238
+ _response: Response,
239
+ next: NextFunction,
240
+ ) {
241
+ if (err instanceof ResponseStatusError) {
242
+ return ResponseEntity.status(err.status).body({ message: err.message });
243
+ }
244
+
245
+ // MUST call next(err) to continue the chain
246
+ next(err);
247
+ }
248
+ }
249
+
250
+ @MiddlewareClass()
251
+ class BaseErrorMiddleware {
252
+ @Middleware()
253
+ handle(
254
+ err: unknown,
255
+ _request: Request,
256
+ _response: Response,
257
+ _next: NextFunction,
258
+ ) {
259
+ console.error("[Error]", err);
260
+
261
+ return ResponseEntity.status(500).body({
262
+ message: "Internal Server Error",
263
+ });
264
+
265
+ // no next(err) since last middleware in chain, we are done propagating
266
+ }
267
+ }
209
268
  ```
210
269
 
211
270
  ### Middleware
@@ -234,10 +293,41 @@ class CookieParserMiddleware {
234
293
  // Register it like any controller
235
294
  app.use(Sapling.resolve(CookieParserMiddleware));
236
295
 
296
+ // Register middlewares before controllers
297
+ app.use(Sapling.resolve(UserController));
298
+
237
299
  // You can also still choose to load plugins the Express.js way
238
300
  app.use(cookieParser());
239
301
  ```
240
302
 
303
+ You can also write custom middlewares as well. It is functionally the same way as Express: call `next()` explicitly to
304
+ continue down the chain:
305
+
306
+ ```typescript
307
+ import { MiddlewareClass, Middleware } from "@tahminator/sapling";
308
+ import { NextFunction, Request, Response } from "express";
309
+
310
+ @MiddlewareClass()
311
+ class RequestTimerMiddleware {
312
+ @Middleware()
313
+ handle(request: Request, _response: Response, next: NextFunction) {
314
+ const start = Date.now();
315
+
316
+ request.on("finish", () => {
317
+ const elapsedMs = Date.now() - start;
318
+ console.log(`[Request] ${request.method} ${request.path} ${elapsedMs}ms`);
319
+ });
320
+
321
+ // MUST call next() to continue the chain
322
+ next();
323
+ }
324
+ }
325
+
326
+ // Register middlewares before controllers
327
+ app.use(Sapling.resolve(RequestTimerMiddleware));
328
+ app.use(Sapling.resolve(UserController));
329
+ ```
330
+
241
331
  ### Request Validation
242
332
 
243
333
  Validate and transform request bodies, route params, and query strings at the controller level using `@RequestBody`, `@RequestParam`, and `@RequestQuery`. These decorators accept any [Standard Schema](https://github.com/standard-schema/standard-schema) compatible validator (Zod, Valibot, ArkType, etc.).
package/dist/index.cjs CHANGED
@@ -505,24 +505,31 @@ function _resolve(ctor) {
505
505
  //#endregion
506
506
  //#region src/annotation/request.ts
507
507
  const _requestSchemaStore = /* @__PURE__ */ new WeakMap();
508
- function _getOrCreateRequestSchemaDefinition(ctor, fnName) {
509
- const byFn = (() => {
510
- const fn = _requestSchemaStore.get(ctor);
511
- if (fn) return fn;
512
- const newFn = /* @__PURE__ */ new Map();
513
- _requestSchemaStore.set(ctor, newFn);
514
- return newFn;
515
- })();
516
- const existing = byFn.get(fnName);
517
- if (existing) return existing;
518
- const created = {};
519
- byFn.set(fnName, created);
520
- return created;
521
- }
522
- function _setOnce(def, key, schema, fnName) {
523
- if (def[key]) throw new Error(`Duplicate request schema for "${String(key)}" on method "${fnName}"`);
524
- def[key] = schema;
525
- }
508
+ /**
509
+ * Apply to a route method to have `request.body` be parsed by `schema`.
510
+ *
511
+ * This annotation will parse `request.body` & then override `request.body`.
512
+ * You can then just simply cast `request.body` for your use
513
+ *
514
+ * @example
515
+ * ```ts
516
+ * const CREATE_BOOK_REQUEST_BODY_SCHEMA = z.object({
517
+ * name: z.string(),
518
+ * description: z.string().optional(),
519
+ * });
520
+ *
521
+ * ⠀@Controller({ prefix: "/api/book" })
522
+ * class BookController {
523
+ * ⠀@RequestBody(CREATE_BOOK_REQUEST_BODY_SCHEMA)
524
+ * ⠀@POST()
525
+ * public createBook(request: e.Request) {
526
+ * const { name, description } = request.body as unknown as z.infer<
527
+ * typeof CREATE_BOOK_REQUEST_BODY_SCHEMA
528
+ * >;
529
+ * }
530
+ * }
531
+ * ```
532
+ */
526
533
  function RequestBody(schema) {
527
534
  return (target, propertyKey) => {
528
535
  const ctor = target.constructor;
@@ -530,6 +537,30 @@ function RequestBody(schema) {
530
537
  _setOnce(_getOrCreateRequestSchemaDefinition(ctor, fnName), "body", schema, fnName);
531
538
  };
532
539
  }
540
+ /**
541
+ * Apply to a route method to have `request.param` be parsed by `schema`.
542
+ *
543
+ * This annotation will parse `request.param` & then override `request.param`.
544
+ * You can then just simply cast `request.param` for your use
545
+ *
546
+ * @example
547
+ * ```ts
548
+ * const GET_BOOK_REQUEST_PARAM_SCHEMA = z.object({
549
+ * bookId: z.string(),
550
+ * });
551
+ *
552
+ * ⠀@Controller({ prefix: "/api/book" })
553
+ * class BookController {
554
+ * ⠀@RequestParam(GET_BOOK_REQUEST_PARAM_SCHEMA)
555
+ * ⠀@GET("/:bookId")
556
+ * public getBook(request: e.Request) {
557
+ * const { bookId } = request.param as unknown as z.infer<
558
+ * typeof GET_BOOK_REQUEST_PARAM_SCHEMA
559
+ * >;
560
+ * }
561
+ * }
562
+ * ```
563
+ */
533
564
  function RequestParam(schema) {
534
565
  return (target, propertyKey) => {
535
566
  const ctor = target.constructor;
@@ -537,6 +568,31 @@ function RequestParam(schema) {
537
568
  _setOnce(_getOrCreateRequestSchemaDefinition(ctor, fnName), "param", schema, fnName);
538
569
  };
539
570
  }
571
+ /**
572
+ * Apply to a route method to have `request.query` be parsed by `schema`.
573
+ *
574
+ * This annotation will parse `request.query` & then override `request.query`.
575
+ * You can then just simply cast `request.query` for your use
576
+ *
577
+ * @example
578
+ * ```ts
579
+ * const LIST_BOOKS_REQUEST_QUERY_SCHEMA = z.object({
580
+ * sort: z.enum(["name", "createdAt"]).optional(),
581
+ * q: z.string().optional(),
582
+ * });
583
+ *
584
+ * ⠀@Controller({ prefix: "/api/book" })
585
+ * class BookController {
586
+ * ⠀@RequestQuery(LIST_BOOKS_REQUEST_QUERY_SCHEMA)
587
+ * ⠀@GET()
588
+ * public listBooks(request: e.Request) {
589
+ * const { sort, q } = request.query as unknown as z.infer<
590
+ * typeof LIST_BOOKS_REQUEST_QUERY_SCHEMA
591
+ * >;
592
+ * }
593
+ * }
594
+ * ```
595
+ */
540
596
  function RequestQuery(schema) {
541
597
  return (target, propertyKey) => {
542
598
  const ctor = target.constructor;
@@ -544,6 +600,24 @@ function RequestQuery(schema) {
544
600
  _setOnce(_getOrCreateRequestSchemaDefinition(ctor, fnName), "query", schema, fnName);
545
601
  };
546
602
  }
603
+ function _getOrCreateRequestSchemaDefinition(ctor, fnName) {
604
+ const byFn = (() => {
605
+ const fn = _requestSchemaStore.get(ctor);
606
+ if (fn) return fn;
607
+ const newFn = /* @__PURE__ */ new Map();
608
+ _requestSchemaStore.set(ctor, newFn);
609
+ return newFn;
610
+ })();
611
+ const existing = byFn.get(fnName);
612
+ if (existing) return existing;
613
+ const created = {};
614
+ byFn.set(fnName, created);
615
+ return created;
616
+ }
617
+ function _setOnce(def, key, schema, fnName) {
618
+ if (def[key]) throw new Error(`Duplicate request schema for "${String(key)}" on method "${fnName}"`);
619
+ def[key] = schema;
620
+ }
547
621
  function _getRequestSchemas(ctor, fnName) {
548
622
  return _requestSchemaStore.get(ctor)?.get(fnName);
549
623
  }
package/dist/index.d.cts CHANGED
@@ -489,8 +489,82 @@ type RequestSchemaDefinition = {
489
489
  param?: StandardSchemaV1;
490
490
  query?: StandardSchemaV1;
491
491
  };
492
+ /**
493
+ * Apply to a route method to have `request.body` be parsed by `schema`.
494
+ *
495
+ * This annotation will parse `request.body` & then override `request.body`.
496
+ * You can then just simply cast `request.body` for your use
497
+ *
498
+ * @example
499
+ * ```ts
500
+ * const CREATE_BOOK_REQUEST_BODY_SCHEMA = z.object({
501
+ * name: z.string(),
502
+ * description: z.string().optional(),
503
+ * });
504
+ *
505
+ * ⠀@Controller({ prefix: "/api/book" })
506
+ * class BookController {
507
+ * ⠀@RequestBody(CREATE_BOOK_REQUEST_BODY_SCHEMA)
508
+ * ⠀@POST()
509
+ * public createBook(request: e.Request) {
510
+ * const { name, description } = request.body as unknown as z.infer<
511
+ * typeof CREATE_BOOK_REQUEST_BODY_SCHEMA
512
+ * >;
513
+ * }
514
+ * }
515
+ * ```
516
+ */
492
517
  declare function RequestBody(schema: StandardSchemaV1): MethodDecorator;
518
+ /**
519
+ * Apply to a route method to have `request.param` be parsed by `schema`.
520
+ *
521
+ * This annotation will parse `request.param` & then override `request.param`.
522
+ * You can then just simply cast `request.param` for your use
523
+ *
524
+ * @example
525
+ * ```ts
526
+ * const GET_BOOK_REQUEST_PARAM_SCHEMA = z.object({
527
+ * bookId: z.string(),
528
+ * });
529
+ *
530
+ * ⠀@Controller({ prefix: "/api/book" })
531
+ * class BookController {
532
+ * ⠀@RequestParam(GET_BOOK_REQUEST_PARAM_SCHEMA)
533
+ * ⠀@GET("/:bookId")
534
+ * public getBook(request: e.Request) {
535
+ * const { bookId } = request.param as unknown as z.infer<
536
+ * typeof GET_BOOK_REQUEST_PARAM_SCHEMA
537
+ * >;
538
+ * }
539
+ * }
540
+ * ```
541
+ */
493
542
  declare function RequestParam(schema: StandardSchemaV1): MethodDecorator;
543
+ /**
544
+ * Apply to a route method to have `request.query` be parsed by `schema`.
545
+ *
546
+ * This annotation will parse `request.query` & then override `request.query`.
547
+ * You can then just simply cast `request.query` for your use
548
+ *
549
+ * @example
550
+ * ```ts
551
+ * const LIST_BOOKS_REQUEST_QUERY_SCHEMA = z.object({
552
+ * sort: z.enum(["name", "createdAt"]).optional(),
553
+ * q: z.string().optional(),
554
+ * });
555
+ *
556
+ * ⠀@Controller({ prefix: "/api/book" })
557
+ * class BookController {
558
+ * ⠀@RequestQuery(LIST_BOOKS_REQUEST_QUERY_SCHEMA)
559
+ * ⠀@GET()
560
+ * public listBooks(request: e.Request) {
561
+ * const { sort, q } = request.query as unknown as z.infer<
562
+ * typeof LIST_BOOKS_REQUEST_QUERY_SCHEMA
563
+ * >;
564
+ * }
565
+ * }
566
+ * ```
567
+ */
494
568
  declare function RequestQuery(schema: StandardSchemaV1): MethodDecorator;
495
569
  declare function _getRequestSchemas(ctor: Function, fnName: string): RequestSchemaDefinition | undefined;
496
570
  declare function _parseOrThrow<TSchema extends StandardSchemaV1>(schema: TSchema, input: unknown, kind: ParserErrorLocation): Promise<StandardSchemaV1.InferOutput<TSchema>>;
package/dist/index.d.mts CHANGED
@@ -489,8 +489,82 @@ type RequestSchemaDefinition = {
489
489
  param?: StandardSchemaV1;
490
490
  query?: StandardSchemaV1;
491
491
  };
492
+ /**
493
+ * Apply to a route method to have `request.body` be parsed by `schema`.
494
+ *
495
+ * This annotation will parse `request.body` & then override `request.body`.
496
+ * You can then just simply cast `request.body` for your use
497
+ *
498
+ * @example
499
+ * ```ts
500
+ * const CREATE_BOOK_REQUEST_BODY_SCHEMA = z.object({
501
+ * name: z.string(),
502
+ * description: z.string().optional(),
503
+ * });
504
+ *
505
+ * ⠀@Controller({ prefix: "/api/book" })
506
+ * class BookController {
507
+ * ⠀@RequestBody(CREATE_BOOK_REQUEST_BODY_SCHEMA)
508
+ * ⠀@POST()
509
+ * public createBook(request: e.Request) {
510
+ * const { name, description } = request.body as unknown as z.infer<
511
+ * typeof CREATE_BOOK_REQUEST_BODY_SCHEMA
512
+ * >;
513
+ * }
514
+ * }
515
+ * ```
516
+ */
492
517
  declare function RequestBody(schema: StandardSchemaV1): MethodDecorator;
518
+ /**
519
+ * Apply to a route method to have `request.param` be parsed by `schema`.
520
+ *
521
+ * This annotation will parse `request.param` & then override `request.param`.
522
+ * You can then just simply cast `request.param` for your use
523
+ *
524
+ * @example
525
+ * ```ts
526
+ * const GET_BOOK_REQUEST_PARAM_SCHEMA = z.object({
527
+ * bookId: z.string(),
528
+ * });
529
+ *
530
+ * ⠀@Controller({ prefix: "/api/book" })
531
+ * class BookController {
532
+ * ⠀@RequestParam(GET_BOOK_REQUEST_PARAM_SCHEMA)
533
+ * ⠀@GET("/:bookId")
534
+ * public getBook(request: e.Request) {
535
+ * const { bookId } = request.param as unknown as z.infer<
536
+ * typeof GET_BOOK_REQUEST_PARAM_SCHEMA
537
+ * >;
538
+ * }
539
+ * }
540
+ * ```
541
+ */
493
542
  declare function RequestParam(schema: StandardSchemaV1): MethodDecorator;
543
+ /**
544
+ * Apply to a route method to have `request.query` be parsed by `schema`.
545
+ *
546
+ * This annotation will parse `request.query` & then override `request.query`.
547
+ * You can then just simply cast `request.query` for your use
548
+ *
549
+ * @example
550
+ * ```ts
551
+ * const LIST_BOOKS_REQUEST_QUERY_SCHEMA = z.object({
552
+ * sort: z.enum(["name", "createdAt"]).optional(),
553
+ * q: z.string().optional(),
554
+ * });
555
+ *
556
+ * ⠀@Controller({ prefix: "/api/book" })
557
+ * class BookController {
558
+ * ⠀@RequestQuery(LIST_BOOKS_REQUEST_QUERY_SCHEMA)
559
+ * ⠀@GET()
560
+ * public listBooks(request: e.Request) {
561
+ * const { sort, q } = request.query as unknown as z.infer<
562
+ * typeof LIST_BOOKS_REQUEST_QUERY_SCHEMA
563
+ * >;
564
+ * }
565
+ * }
566
+ * ```
567
+ */
494
568
  declare function RequestQuery(schema: StandardSchemaV1): MethodDecorator;
495
569
  declare function _getRequestSchemas(ctor: Function, fnName: string): RequestSchemaDefinition | undefined;
496
570
  declare function _parseOrThrow<TSchema extends StandardSchemaV1>(schema: TSchema, input: unknown, kind: ParserErrorLocation): Promise<StandardSchemaV1.InferOutput<TSchema>>;
package/dist/index.mjs CHANGED
@@ -481,24 +481,31 @@ function _resolve(ctor) {
481
481
  //#endregion
482
482
  //#region src/annotation/request.ts
483
483
  const _requestSchemaStore = /* @__PURE__ */ new WeakMap();
484
- function _getOrCreateRequestSchemaDefinition(ctor, fnName) {
485
- const byFn = (() => {
486
- const fn = _requestSchemaStore.get(ctor);
487
- if (fn) return fn;
488
- const newFn = /* @__PURE__ */ new Map();
489
- _requestSchemaStore.set(ctor, newFn);
490
- return newFn;
491
- })();
492
- const existing = byFn.get(fnName);
493
- if (existing) return existing;
494
- const created = {};
495
- byFn.set(fnName, created);
496
- return created;
497
- }
498
- function _setOnce(def, key, schema, fnName) {
499
- if (def[key]) throw new Error(`Duplicate request schema for "${String(key)}" on method "${fnName}"`);
500
- def[key] = schema;
501
- }
484
+ /**
485
+ * Apply to a route method to have `request.body` be parsed by `schema`.
486
+ *
487
+ * This annotation will parse `request.body` & then override `request.body`.
488
+ * You can then just simply cast `request.body` for your use
489
+ *
490
+ * @example
491
+ * ```ts
492
+ * const CREATE_BOOK_REQUEST_BODY_SCHEMA = z.object({
493
+ * name: z.string(),
494
+ * description: z.string().optional(),
495
+ * });
496
+ *
497
+ * ⠀@Controller({ prefix: "/api/book" })
498
+ * class BookController {
499
+ * ⠀@RequestBody(CREATE_BOOK_REQUEST_BODY_SCHEMA)
500
+ * ⠀@POST()
501
+ * public createBook(request: e.Request) {
502
+ * const { name, description } = request.body as unknown as z.infer<
503
+ * typeof CREATE_BOOK_REQUEST_BODY_SCHEMA
504
+ * >;
505
+ * }
506
+ * }
507
+ * ```
508
+ */
502
509
  function RequestBody(schema) {
503
510
  return (target, propertyKey) => {
504
511
  const ctor = target.constructor;
@@ -506,6 +513,30 @@ function RequestBody(schema) {
506
513
  _setOnce(_getOrCreateRequestSchemaDefinition(ctor, fnName), "body", schema, fnName);
507
514
  };
508
515
  }
516
+ /**
517
+ * Apply to a route method to have `request.param` be parsed by `schema`.
518
+ *
519
+ * This annotation will parse `request.param` & then override `request.param`.
520
+ * You can then just simply cast `request.param` for your use
521
+ *
522
+ * @example
523
+ * ```ts
524
+ * const GET_BOOK_REQUEST_PARAM_SCHEMA = z.object({
525
+ * bookId: z.string(),
526
+ * });
527
+ *
528
+ * ⠀@Controller({ prefix: "/api/book" })
529
+ * class BookController {
530
+ * ⠀@RequestParam(GET_BOOK_REQUEST_PARAM_SCHEMA)
531
+ * ⠀@GET("/:bookId")
532
+ * public getBook(request: e.Request) {
533
+ * const { bookId } = request.param as unknown as z.infer<
534
+ * typeof GET_BOOK_REQUEST_PARAM_SCHEMA
535
+ * >;
536
+ * }
537
+ * }
538
+ * ```
539
+ */
509
540
  function RequestParam(schema) {
510
541
  return (target, propertyKey) => {
511
542
  const ctor = target.constructor;
@@ -513,6 +544,31 @@ function RequestParam(schema) {
513
544
  _setOnce(_getOrCreateRequestSchemaDefinition(ctor, fnName), "param", schema, fnName);
514
545
  };
515
546
  }
547
+ /**
548
+ * Apply to a route method to have `request.query` be parsed by `schema`.
549
+ *
550
+ * This annotation will parse `request.query` & then override `request.query`.
551
+ * You can then just simply cast `request.query` for your use
552
+ *
553
+ * @example
554
+ * ```ts
555
+ * const LIST_BOOKS_REQUEST_QUERY_SCHEMA = z.object({
556
+ * sort: z.enum(["name", "createdAt"]).optional(),
557
+ * q: z.string().optional(),
558
+ * });
559
+ *
560
+ * ⠀@Controller({ prefix: "/api/book" })
561
+ * class BookController {
562
+ * ⠀@RequestQuery(LIST_BOOKS_REQUEST_QUERY_SCHEMA)
563
+ * ⠀@GET()
564
+ * public listBooks(request: e.Request) {
565
+ * const { sort, q } = request.query as unknown as z.infer<
566
+ * typeof LIST_BOOKS_REQUEST_QUERY_SCHEMA
567
+ * >;
568
+ * }
569
+ * }
570
+ * ```
571
+ */
516
572
  function RequestQuery(schema) {
517
573
  return (target, propertyKey) => {
518
574
  const ctor = target.constructor;
@@ -520,6 +576,24 @@ function RequestQuery(schema) {
520
576
  _setOnce(_getOrCreateRequestSchemaDefinition(ctor, fnName), "query", schema, fnName);
521
577
  };
522
578
  }
579
+ function _getOrCreateRequestSchemaDefinition(ctor, fnName) {
580
+ const byFn = (() => {
581
+ const fn = _requestSchemaStore.get(ctor);
582
+ if (fn) return fn;
583
+ const newFn = /* @__PURE__ */ new Map();
584
+ _requestSchemaStore.set(ctor, newFn);
585
+ return newFn;
586
+ })();
587
+ const existing = byFn.get(fnName);
588
+ if (existing) return existing;
589
+ const created = {};
590
+ byFn.set(fnName, created);
591
+ return created;
592
+ }
593
+ function _setOnce(def, key, schema, fnName) {
594
+ if (def[key]) throw new Error(`Duplicate request schema for "${String(key)}" on method "${fnName}"`);
595
+ def[key] = schema;
596
+ }
523
597
  function _getRequestSchemas(ctor, fnName) {
524
598
  return _requestSchemaStore.get(ctor)?.get(fnName);
525
599
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tahminator/sapling",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "author": "Tahmid Ahmed",
5
5
  "description": "A library to help you write cleaner Express.js code",
6
6
  "repository": {