@tahminator/sapling 1.5.28 → 2.0.0

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
@@ -19,6 +19,7 @@ A lightweight Express.js dependency injection & route abstraction library.
19
19
  * [Responses](#responses)
20
20
  * [Error Handling](#error-handling)
21
21
  * [Middleware](#middleware)
22
+ * [Request Validation](#request-validation)
22
23
  * [Redirects](#redirects)
23
24
  * [Dependency Injection](#dependency-injection)
24
25
  * [Custom Serialization](#custom-serialization)
@@ -142,8 +143,11 @@ Sapling supports the usual suspects:
142
143
  - `@DELETE(path?)`
143
144
  - `@PATCH(path?)`
144
145
  - `@Middleware(path?)` - for middleware
146
+ - `@RequestBody(schema)` - validate & parse the request body
147
+ - `@RequestParam(schema)` - validate & parse route params
148
+ - `@RequestQuery(schema)` - validate & parse the query string
145
149
 
146
- Path defaults to `"/"` if you don't pass one.
150
+ Path defaults to `"/"` if you don't pass one. The request schema decorators accept any [Standard Schema](https://github.com/standard-schema/standard-schema) compatible validator (e.g. Zod, Valibot, ArkType).
147
151
 
148
152
  ### Responses
149
153
 
@@ -234,6 +238,56 @@ app.use(Sapling.resolve(CookieParserMiddleware));
234
238
  app.use(cookieParser());
235
239
  ```
236
240
 
241
+ ### Request Validation
242
+
243
+ 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.).
244
+
245
+ If validation fails, a `ParserError` is thrown, which Express handles as a `400 Bad Request` by default:
246
+
247
+ ```typescript
248
+ import { z } from "zod";
249
+
250
+ const CreateUserSchema = z.object({ name: z.string(), age: z.number() });
251
+ const UserParamsSchema = z.object({ id: z.string() });
252
+ const ListUsersQuerySchema = z.object({ page: z.coerce.number() });
253
+
254
+ @Controller({ prefix: "/users" })
255
+ class UserController {
256
+ @RequestBody(CreateUserSchema)
257
+ @POST()
258
+ createUser(request: Request): ResponseEntity<User> {
259
+ // request.body has been fully validated and rewritten. you can safely assert the type!
260
+ const requestBody = request.body as unknown as z.infer<CreateUserSchema>;
261
+
262
+ const user = this.database.user.create(requestBody.name, requestBody.age)
263
+
264
+ return ResponseEntity.ok().body(user);
265
+ }
266
+
267
+ @RequestParam(UserParamsSchema)
268
+ @GET("/:id")
269
+ getUser(request: Request): ResponseEntity<z.infer<typeof UserParamsSchema>> {
270
+ // request.params has been fully validated and rewritten. you can safely assert the type!
271
+ const params = request.params as unknown as z.infer<typeof UserParamsSchema>;
272
+
273
+ const user = this.database.user.findById(params.id);
274
+
275
+ return ResponseEntity.ok().body(user);
276
+ }
277
+
278
+ @RequestQuery(ListUsersQuerySchema)
279
+ @GET()
280
+ listUsers(request: Request): ResponseEntity<z.infer<typeof ListUsersQuerySchema>> {
281
+ // request.query has been fully validated and rewritten. you can safely assert the type!
282
+ const query = request.query as unknown as z.infer<typeof ListUsersQuerySchema>;
283
+
284
+ const users = this.database.user.findAll({ page: query.page });
285
+
286
+ return ResponseEntity.ok().body(users);
287
+ }
288
+ }
289
+ ```
290
+
237
291
  ### Redirects
238
292
 
239
293
  ```typescript
package/dist/index.cjs CHANGED
@@ -221,6 +221,22 @@ var ResponseEntityBuilder = class {
221
221
  }
222
222
  };
223
223
  //#endregion
224
+ //#region src/helper/error/responsestatus.ts
225
+ /**
226
+ * Ensure that you define a middleware that can handle this error.
227
+ *
228
+ * @see {@link Sapling.loadResponseStatusErrorMiddleware}
229
+ */
230
+ var ResponseStatusError = class ResponseStatusError extends Error {
231
+ constructor(status, message) {
232
+ super(message ?? "Something went wrong.");
233
+ this.status = status;
234
+ Object.setPrototypeOf(this, new.target.prototype);
235
+ this.name = `HttpError(${HttpStatus[status]})`;
236
+ if (Error.captureStackTrace) Error.captureStackTrace(this, ResponseStatusError);
237
+ }
238
+ };
239
+ //#endregion
224
240
  //#region src/helper/sapling.ts
225
241
  const settings = {
226
242
  serialize: JSON.stringify,
@@ -294,34 +310,6 @@ var Sapling = class Sapling {
294
310
  app.use(Sapling.json());
295
311
  }
296
312
  /**
297
- * Register a middleware that will handle {@link ResponseStatusError}.
298
- *
299
- * This middleware will chain to the next middleware if it does not catch {@link ResponseStatusError}.
300
- * You may still define middleware to handle all other errors in a separate `app.use` call.
301
- *
302
- * @example
303
- * ```ts
304
- * import express from "express";
305
- * import { Sapling } from "@tahminator/sapling";
306
- *
307
- * const app = express();
308
- *
309
- * Sapling.loadResponseStatusErrorMiddleware(app, (err, req, res, next) => {
310
- * // `err` is guaranteed to be of type ResponseStatusError
311
- * res.status(err.status).json({
312
- * success: false,
313
- * message: err.message,
314
- * });
315
- * });
316
- * ```
317
- */
318
- static loadResponseStatusErrorMiddleware(app, fn) {
319
- app.use(((err, req, res, next) => {
320
- if (err instanceof ResponseStatusError) fn(err, req, res, next);
321
- else next(err);
322
- }));
323
- }
324
- /**
325
313
  * Serialize a value into a JSON string.
326
314
  *
327
315
  * This function is used in {@link ResponseEntity} to serialize the `body`.
@@ -359,22 +347,6 @@ var Sapling = class Sapling {
359
347
  }
360
348
  };
361
349
  //#endregion
362
- //#region src/helper/error.ts
363
- /**
364
- * Ensure that you define a middleware that can handle this error.
365
- *
366
- * @see {@link Sapling.loadResponseStatusErrorMiddleware}
367
- */
368
- var ResponseStatusError = class ResponseStatusError extends Error {
369
- constructor(status, message) {
370
- super(message ?? "Something went wrong.");
371
- this.status = status;
372
- Object.setPrototypeOf(this, new.target.prototype);
373
- this.name = `HttpError(${HttpStatus[status]})`;
374
- if (Error.captureStackTrace) Error.captureStackTrace(this, ResponseStatusError);
375
- }
376
- };
377
- //#endregion
378
350
  //#region src/types.ts
379
351
  const methodResolve = {
380
352
  GET: "get",
@@ -507,6 +479,80 @@ function _resolve(ctor) {
507
479
  return _InjectableRegistry.get(ctor);
508
480
  }
509
481
  //#endregion
482
+ //#region src/helper/error/exception.ts
483
+ /**
484
+ * This error should be thrown when some data cannot be parsed by a given schema.
485
+ */
486
+ var ParserError = class ParserError extends ResponseStatusError {
487
+ constructor(location, issues, vendor) {
488
+ super(400, ParserError.formatMessage(location, issues, vendor));
489
+ Object.setPrototypeOf(this, new.target.prototype);
490
+ }
491
+ static formatMessage(location, issues, vendor) {
492
+ const formatted = issues.map((i) => {
493
+ const path = Array.isArray(i.path) ? i.path.map((seg) => typeof seg === "object" && seg ? String(seg.key) : String(seg)).join(".") : "";
494
+ return path ? `${path}: ${i.message}` : i.message;
495
+ }).join("; ");
496
+ return `${vendor} failed to parse ${(() => {
497
+ switch (location) {
498
+ case "reqbody": return "request body";
499
+ case "reqparams": return "request params";
500
+ case "reqquery": return "request query";
501
+ }
502
+ })()}: ${formatted}`;
503
+ }
504
+ };
505
+ //#endregion
506
+ //#region src/annotation/request.ts
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
+ }
526
+ function RequestBody(schema) {
527
+ return (target, propertyKey) => {
528
+ const ctor = target.constructor;
529
+ const fnName = String(propertyKey);
530
+ _setOnce(_getOrCreateRequestSchemaDefinition(ctor, fnName), "body", schema, fnName);
531
+ };
532
+ }
533
+ function RequestParam(schema) {
534
+ return (target, propertyKey) => {
535
+ const ctor = target.constructor;
536
+ const fnName = String(propertyKey);
537
+ _setOnce(_getOrCreateRequestSchemaDefinition(ctor, fnName), "param", schema, fnName);
538
+ };
539
+ }
540
+ function RequestQuery(schema) {
541
+ return (target, propertyKey) => {
542
+ const ctor = target.constructor;
543
+ const fnName = String(propertyKey);
544
+ _setOnce(_getOrCreateRequestSchemaDefinition(ctor, fnName), "query", schema, fnName);
545
+ };
546
+ }
547
+ function _getRequestSchemas(ctor, fnName) {
548
+ return _requestSchemaStore.get(ctor)?.get(fnName);
549
+ }
550
+ async function _parseOrThrow(schema, input, kind) {
551
+ const result = await schema["~standard"].validate(input);
552
+ if (result.issues) throw new ParserError(kind, result.issues, schema["~standard"].vendor);
553
+ return result.value;
554
+ }
555
+ //#endregion
510
556
  //#region src/annotation/route.ts
511
557
  const _routeStore = /* @__PURE__ */ new WeakMap();
512
558
  /**
@@ -604,6 +650,14 @@ function Controller({ prefix = "", deps = [] } = {}) {
604
650
  const usedRoutes = /* @__PURE__ */ new Set();
605
651
  _InjectableDeps.set(targetClass, deps);
606
652
  const controllerInstance = _resolve(targetClass);
653
+ if (routes.reduce((prev, r) => {
654
+ if (r.method !== "USE") return prev;
655
+ const fn = controllerInstance[r.fnName];
656
+ return typeof fn === "function" && fn.length >= 4 ? prev + 1 : prev;
657
+ }, 0) > 1) throw new Error(`Invalid @MiddlewareClass class "${targetClass.name}":
658
+ Multiple 4-arg @Middleware() error handlers were found.
659
+ Express will not enter routers in error mode, so an error-middleware class must expose exactly one error handler.
660
+ Split these into separate @MiddlewareClass classes, or merge the logic into a single method.`);
607
661
  for (const { method, path, fnName } of routes) {
608
662
  const fn = controllerInstance[fnName];
609
663
  if (typeof fn !== "function") continue;
@@ -612,9 +666,34 @@ function Controller({ prefix = "", deps = [] } = {}) {
612
666
  if (method !== "USE" && usedRoutes.has(routeKey)) throw new Error(`Duplicate route [${method}] "${path instanceof RegExp ? path.source : fp}" detected in controller "${target.name}"`);
613
667
  if (method !== "USE") usedRoutes.add(routeKey);
614
668
  const methodName = methodResolve[method];
669
+ if (method === "USE" && fn.length >= 4) {
670
+ const middlewareFn = async (err, request, response, next) => {
671
+ try {
672
+ const result = fn.bind(controllerInstance)(err, request, response, next);
673
+ if (result instanceof ResponseEntity) {
674
+ response.contentType("application/json").status(result.getStatusCode()).set(result.getHeaders()).send(Sapling.serialize(result.getBody()));
675
+ return;
676
+ }
677
+ if (result instanceof RedirectView) {
678
+ response.redirect(result.getUrl());
679
+ return;
680
+ }
681
+ } catch (e) {
682
+ console.error(e);
683
+ next(e);
684
+ }
685
+ };
686
+ _ControllerRegistry.set(targetClass, middlewareFn);
687
+ return;
688
+ }
615
689
  router[methodName](fp, async (request, response, next) => {
690
+ const schemas = _getRequestSchemas(target, fnName);
691
+ if (schemas) {
692
+ if (schemas.body) request.body = await _parseOrThrow(schemas.body, request.body, "reqbody");
693
+ if (schemas.param) request.params = await _parseOrThrow(schemas.param, request.params, "reqparams");
694
+ if (schemas.query) request.query = await _parseOrThrow(schemas.query, request.query, "reqquery");
695
+ }
616
696
  const result = await fn.bind(controllerInstance)(request, response, next);
617
- if (method === "USE") return;
618
697
  if (result instanceof ResponseEntity) {
619
698
  response.contentType("application/json").status(result.getStatusCode()).set(result.getHeaders()).send(Sapling.serialize(result.getBody()));
620
699
  return;
@@ -623,7 +702,7 @@ function Controller({ prefix = "", deps = [] } = {}) {
623
702
  response.redirect(result.getUrl());
624
703
  return;
625
704
  }
626
- if (!response.writableEnded) response.status(404).send(Html404ErrorPage(`Cannot ${methodName.toUpperCase()} ${path instanceof RegExp ? path.source : fp}`));
705
+ if (method !== "USE" && !response.writableEnded) response.status(404).send(Html404ErrorPage(`Cannot ${methodName.toUpperCase()} ${path instanceof RegExp ? path.source : fp}`));
627
706
  });
628
707
  }
629
708
  _ControllerRegistry.set(targetClass, router);
@@ -655,7 +734,11 @@ exports.OPTIONS = OPTIONS;
655
734
  exports.PATCH = PATCH;
656
735
  exports.POST = POST;
657
736
  exports.PUT = PUT;
737
+ exports.ParserError = ParserError;
658
738
  exports.RedirectView = RedirectView;
739
+ exports.RequestBody = RequestBody;
740
+ exports.RequestParam = RequestParam;
741
+ exports.RequestQuery = RequestQuery;
659
742
  exports.ResponseEntity = ResponseEntity;
660
743
  exports.ResponseEntityBuilder = ResponseEntityBuilder;
661
744
  exports.ResponseStatusError = ResponseStatusError;
@@ -664,6 +747,8 @@ exports._ControllerRegistry = _ControllerRegistry;
664
747
  exports._InjectableDeps = _InjectableDeps;
665
748
  exports._InjectableRegistry = _InjectableRegistry;
666
749
  exports._Route = _Route;
750
+ exports._getRequestSchemas = _getRequestSchemas;
667
751
  exports._getRoutes = _getRoutes;
752
+ exports._parseOrThrow = _parseOrThrow;
668
753
  exports._resolve = _resolve;
669
754
  exports.methodResolve = methodResolve;
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import e, { NextFunction, Request, Response, Router } from "express";
1
+ import e, { ErrorRequestHandler, NextFunction, Request, Response, Router } from "express";
2
2
 
3
3
  //#region src/html/404.d.ts
4
4
  /**
@@ -29,7 +29,7 @@ type HttpHeaders = Record<string, string>;
29
29
  type ExpressMiddlewareFn = ($1: Request, $2: Response, $3: NextFunction) => void;
30
30
  //#endregion
31
31
  //#region src/annotation/controller.d.ts
32
- declare const _ControllerRegistry: WeakMap<Function, Router>;
32
+ declare const _ControllerRegistry: WeakMap<Function, Router | ErrorRequestHandler>;
33
33
  type ControllerProps = {
34
34
  /**
35
35
  * Optional URL prefix applied to all routes in the controller. Defaults to "".
@@ -153,6 +153,84 @@ declare function _getRoutes(ctor: Function): readonly RouteDefinition[];
153
153
  */
154
154
  declare function MiddlewareClass(...args: Parameters<typeof Controller>): ClassDecorator;
155
155
  //#endregion
156
+ //#region node_modules/.pnpm/@standard-schema+spec@1.1.0/node_modules/@standard-schema/spec/dist/index.d.ts
157
+ /** The Standard Typed interface. This is a base type extended by other specs. */
158
+ interface StandardTypedV1<Input = unknown, Output = Input> {
159
+ /** The Standard properties. */
160
+ readonly "~standard": StandardTypedV1.Props<Input, Output>;
161
+ }
162
+ declare namespace StandardTypedV1 {
163
+ /** The Standard Typed properties interface. */
164
+ interface Props<Input = unknown, Output = Input> {
165
+ /** The version number of the standard. */
166
+ readonly version: 1;
167
+ /** The vendor name of the schema library. */
168
+ readonly vendor: string;
169
+ /** Inferred types associated with the schema. */
170
+ readonly types?: Types<Input, Output> | undefined;
171
+ }
172
+ /** The Standard Typed types interface. */
173
+ interface Types<Input = unknown, Output = Input> {
174
+ /** The input type of the schema. */
175
+ readonly input: Input;
176
+ /** The output type of the schema. */
177
+ readonly output: Output;
178
+ }
179
+ /** Infers the input type of a Standard Typed. */
180
+ type InferInput<Schema extends StandardTypedV1> = NonNullable<Schema["~standard"]["types"]>["input"];
181
+ /** Infers the output type of a Standard Typed. */
182
+ type InferOutput<Schema extends StandardTypedV1> = NonNullable<Schema["~standard"]["types"]>["output"];
183
+ }
184
+ /** The Standard Schema interface. */
185
+ interface StandardSchemaV1<Input = unknown, Output = Input> {
186
+ /** The Standard Schema properties. */
187
+ readonly "~standard": StandardSchemaV1.Props<Input, Output>;
188
+ }
189
+ declare namespace StandardSchemaV1 {
190
+ /** The Standard Schema properties interface. */
191
+ interface Props<Input = unknown, Output = Input> extends StandardTypedV1.Props<Input, Output> {
192
+ /** Validates unknown input values. */
193
+ readonly validate: (value: unknown, options?: StandardSchemaV1.Options | undefined) => Result<Output> | Promise<Result<Output>>;
194
+ }
195
+ /** The result interface of the validate function. */
196
+ type Result<Output> = SuccessResult<Output> | FailureResult;
197
+ /** The result interface if validation succeeds. */
198
+ interface SuccessResult<Output> {
199
+ /** The typed output value. */
200
+ readonly value: Output;
201
+ /** A falsy value for `issues` indicates success. */
202
+ readonly issues?: undefined;
203
+ }
204
+ interface Options {
205
+ /** Explicit support for additional vendor-specific parameters, if needed. */
206
+ readonly libraryOptions?: Record<string, unknown> | undefined;
207
+ }
208
+ /** The result interface if validation fails. */
209
+ interface FailureResult {
210
+ /** The issues of failed validation. */
211
+ readonly issues: ReadonlyArray<Issue>;
212
+ }
213
+ /** The issue interface of the failure output. */
214
+ interface Issue {
215
+ /** The error message of the issue. */
216
+ readonly message: string;
217
+ /** The path of the issue, if any. */
218
+ readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
219
+ }
220
+ /** The path segment interface of the issue. */
221
+ interface PathSegment {
222
+ /** The key representing a path segment. */
223
+ readonly key: PropertyKey;
224
+ }
225
+ /** The Standard types interface. */
226
+ interface Types<Input = unknown, Output = Input> extends StandardTypedV1.Types<Input, Output> {}
227
+ /** Infers the input type of a Standard. */
228
+ type InferInput<Schema extends StandardTypedV1> = StandardTypedV1.InferInput<Schema>;
229
+ /** Infers the output type of a Standard. */
230
+ type InferOutput<Schema extends StandardTypedV1> = StandardTypedV1.InferOutput<Schema>;
231
+ }
232
+ /** The Standard JSON Schema interface. */
233
+ //#endregion
156
234
  //#region src/helper/redirect.d.ts
157
235
  /**
158
236
  * Generic HTTP redirect wrapped modeled after Spring's `RedirectView`.
@@ -308,7 +386,7 @@ declare enum HttpStatus {
308
386
  NETWORK_AUTHENTICATION_REQUIRED = 511
309
387
  }
310
388
  //#endregion
311
- //#region src/helper/error.d.ts
389
+ //#region src/helper/error/responsestatus.d.ts
312
390
  /**
313
391
  * Ensure that you define a middleware that can handle this error.
314
392
  *
@@ -338,7 +416,7 @@ declare class Sapling {
338
416
  * app.use(router);
339
417
  * ```
340
418
  */
341
- static resolve<TClass>(this: void, clazz: Class<TClass>): Router;
419
+ static resolve<TClass>(this: void, clazz: Class<TClass>): Router | ErrorRequestHandler;
342
420
  /**
343
421
  * Register this function as a middleware in order to utilize Sapling's `deserialize` function.
344
422
  *
@@ -365,29 +443,6 @@ declare class Sapling {
365
443
  * ```
366
444
  */
367
445
  static registerApp(app: e.Express): void;
368
- /**
369
- * Register a middleware that will handle {@link ResponseStatusError}.
370
- *
371
- * This middleware will chain to the next middleware if it does not catch {@link ResponseStatusError}.
372
- * You may still define middleware to handle all other errors in a separate `app.use` call.
373
- *
374
- * @example
375
- * ```ts
376
- * import express from "express";
377
- * import { Sapling } from "@tahminator/sapling";
378
- *
379
- * const app = express();
380
- *
381
- * Sapling.loadResponseStatusErrorMiddleware(app, (err, req, res, next) => {
382
- * // `err` is guaranteed to be of type ResponseStatusError
383
- * res.status(err.status).json({
384
- * success: false,
385
- * message: err.message,
386
- * });
387
- * });
388
- * ```
389
- */
390
- static loadResponseStatusErrorMiddleware(this: void, app: e.Express, fn: (err: ResponseStatusError, request: e.Request, response: e.Response, next: e.NextFunction) => void): void;
391
446
  /**
392
447
  * Serialize a value into a JSON string.
393
448
  *
@@ -418,4 +473,26 @@ declare class Sapling {
418
473
  static setDeserializeFn(this: void, fn: (value: string) => any): void;
419
474
  }
420
475
  //#endregion
421
- export { Class, Controller, DELETE, ExpressMiddlewareFn, ExpressRouterMethodKey, ExpressRouterMethods, GET, HEAD, Html404ErrorPage, HttpHeaders, HttpStatus, Injectable, Middleware, MiddlewareClass, OPTIONS, PATCH, POST, PUT, RedirectView, ResponseEntity, ResponseEntityBuilder, ResponseStatusError, RouteDefinition, Sapling, _ControllerRegistry, _InjectableDeps, _InjectableRegistry, _Route, _getRoutes, _resolve, methodResolve };
476
+ //#region src/helper/error/exception.d.ts
477
+ type ParserErrorLocation = "reqbody" | "reqparams" | "reqquery";
478
+ /**
479
+ * This error should be thrown when some data cannot be parsed by a given schema.
480
+ */
481
+ declare class ParserError extends ResponseStatusError {
482
+ constructor(location: ParserErrorLocation, issues: readonly StandardSchemaV1.Issue[], vendor: string);
483
+ private static formatMessage;
484
+ }
485
+ //#endregion
486
+ //#region src/annotation/request.d.ts
487
+ type RequestSchemaDefinition = {
488
+ body?: StandardSchemaV1;
489
+ param?: StandardSchemaV1;
490
+ query?: StandardSchemaV1;
491
+ };
492
+ declare function RequestBody(schema: StandardSchemaV1): MethodDecorator;
493
+ declare function RequestParam(schema: StandardSchemaV1): MethodDecorator;
494
+ declare function RequestQuery(schema: StandardSchemaV1): MethodDecorator;
495
+ declare function _getRequestSchemas(ctor: Function, fnName: string): RequestSchemaDefinition | undefined;
496
+ declare function _parseOrThrow<TSchema extends StandardSchemaV1>(schema: TSchema, input: unknown, kind: ParserErrorLocation): Promise<StandardSchemaV1.InferOutput<TSchema>>;
497
+ //#endregion
498
+ export { Class, Controller, DELETE, ExpressMiddlewareFn, ExpressRouterMethodKey, ExpressRouterMethods, GET, HEAD, Html404ErrorPage, HttpHeaders, HttpStatus, Injectable, Middleware, MiddlewareClass, OPTIONS, PATCH, POST, PUT, ParserError, ParserErrorLocation, RedirectView, RequestBody, RequestParam, RequestQuery, ResponseEntity, ResponseEntityBuilder, ResponseStatusError, RouteDefinition, Sapling, _ControllerRegistry, _InjectableDeps, _InjectableRegistry, _Route, _getRequestSchemas, _getRoutes, _parseOrThrow, _resolve, methodResolve };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import e, { NextFunction, Request, Response, Router } from "express";
1
+ import e, { ErrorRequestHandler, NextFunction, Request, Response, Router } from "express";
2
2
 
3
3
  //#region src/html/404.d.ts
4
4
  /**
@@ -29,7 +29,7 @@ type HttpHeaders = Record<string, string>;
29
29
  type ExpressMiddlewareFn = ($1: Request, $2: Response, $3: NextFunction) => void;
30
30
  //#endregion
31
31
  //#region src/annotation/controller.d.ts
32
- declare const _ControllerRegistry: WeakMap<Function, Router>;
32
+ declare const _ControllerRegistry: WeakMap<Function, Router | ErrorRequestHandler>;
33
33
  type ControllerProps = {
34
34
  /**
35
35
  * Optional URL prefix applied to all routes in the controller. Defaults to "".
@@ -153,6 +153,84 @@ declare function _getRoutes(ctor: Function): readonly RouteDefinition[];
153
153
  */
154
154
  declare function MiddlewareClass(...args: Parameters<typeof Controller>): ClassDecorator;
155
155
  //#endregion
156
+ //#region node_modules/.pnpm/@standard-schema+spec@1.1.0/node_modules/@standard-schema/spec/dist/index.d.ts
157
+ /** The Standard Typed interface. This is a base type extended by other specs. */
158
+ interface StandardTypedV1<Input = unknown, Output = Input> {
159
+ /** The Standard properties. */
160
+ readonly "~standard": StandardTypedV1.Props<Input, Output>;
161
+ }
162
+ declare namespace StandardTypedV1 {
163
+ /** The Standard Typed properties interface. */
164
+ interface Props<Input = unknown, Output = Input> {
165
+ /** The version number of the standard. */
166
+ readonly version: 1;
167
+ /** The vendor name of the schema library. */
168
+ readonly vendor: string;
169
+ /** Inferred types associated with the schema. */
170
+ readonly types?: Types<Input, Output> | undefined;
171
+ }
172
+ /** The Standard Typed types interface. */
173
+ interface Types<Input = unknown, Output = Input> {
174
+ /** The input type of the schema. */
175
+ readonly input: Input;
176
+ /** The output type of the schema. */
177
+ readonly output: Output;
178
+ }
179
+ /** Infers the input type of a Standard Typed. */
180
+ type InferInput<Schema extends StandardTypedV1> = NonNullable<Schema["~standard"]["types"]>["input"];
181
+ /** Infers the output type of a Standard Typed. */
182
+ type InferOutput<Schema extends StandardTypedV1> = NonNullable<Schema["~standard"]["types"]>["output"];
183
+ }
184
+ /** The Standard Schema interface. */
185
+ interface StandardSchemaV1<Input = unknown, Output = Input> {
186
+ /** The Standard Schema properties. */
187
+ readonly "~standard": StandardSchemaV1.Props<Input, Output>;
188
+ }
189
+ declare namespace StandardSchemaV1 {
190
+ /** The Standard Schema properties interface. */
191
+ interface Props<Input = unknown, Output = Input> extends StandardTypedV1.Props<Input, Output> {
192
+ /** Validates unknown input values. */
193
+ readonly validate: (value: unknown, options?: StandardSchemaV1.Options | undefined) => Result<Output> | Promise<Result<Output>>;
194
+ }
195
+ /** The result interface of the validate function. */
196
+ type Result<Output> = SuccessResult<Output> | FailureResult;
197
+ /** The result interface if validation succeeds. */
198
+ interface SuccessResult<Output> {
199
+ /** The typed output value. */
200
+ readonly value: Output;
201
+ /** A falsy value for `issues` indicates success. */
202
+ readonly issues?: undefined;
203
+ }
204
+ interface Options {
205
+ /** Explicit support for additional vendor-specific parameters, if needed. */
206
+ readonly libraryOptions?: Record<string, unknown> | undefined;
207
+ }
208
+ /** The result interface if validation fails. */
209
+ interface FailureResult {
210
+ /** The issues of failed validation. */
211
+ readonly issues: ReadonlyArray<Issue>;
212
+ }
213
+ /** The issue interface of the failure output. */
214
+ interface Issue {
215
+ /** The error message of the issue. */
216
+ readonly message: string;
217
+ /** The path of the issue, if any. */
218
+ readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined;
219
+ }
220
+ /** The path segment interface of the issue. */
221
+ interface PathSegment {
222
+ /** The key representing a path segment. */
223
+ readonly key: PropertyKey;
224
+ }
225
+ /** The Standard types interface. */
226
+ interface Types<Input = unknown, Output = Input> extends StandardTypedV1.Types<Input, Output> {}
227
+ /** Infers the input type of a Standard. */
228
+ type InferInput<Schema extends StandardTypedV1> = StandardTypedV1.InferInput<Schema>;
229
+ /** Infers the output type of a Standard. */
230
+ type InferOutput<Schema extends StandardTypedV1> = StandardTypedV1.InferOutput<Schema>;
231
+ }
232
+ /** The Standard JSON Schema interface. */
233
+ //#endregion
156
234
  //#region src/helper/redirect.d.ts
157
235
  /**
158
236
  * Generic HTTP redirect wrapped modeled after Spring's `RedirectView`.
@@ -308,7 +386,7 @@ declare enum HttpStatus {
308
386
  NETWORK_AUTHENTICATION_REQUIRED = 511
309
387
  }
310
388
  //#endregion
311
- //#region src/helper/error.d.ts
389
+ //#region src/helper/error/responsestatus.d.ts
312
390
  /**
313
391
  * Ensure that you define a middleware that can handle this error.
314
392
  *
@@ -338,7 +416,7 @@ declare class Sapling {
338
416
  * app.use(router);
339
417
  * ```
340
418
  */
341
- static resolve<TClass>(this: void, clazz: Class<TClass>): Router;
419
+ static resolve<TClass>(this: void, clazz: Class<TClass>): Router | ErrorRequestHandler;
342
420
  /**
343
421
  * Register this function as a middleware in order to utilize Sapling's `deserialize` function.
344
422
  *
@@ -365,29 +443,6 @@ declare class Sapling {
365
443
  * ```
366
444
  */
367
445
  static registerApp(app: e.Express): void;
368
- /**
369
- * Register a middleware that will handle {@link ResponseStatusError}.
370
- *
371
- * This middleware will chain to the next middleware if it does not catch {@link ResponseStatusError}.
372
- * You may still define middleware to handle all other errors in a separate `app.use` call.
373
- *
374
- * @example
375
- * ```ts
376
- * import express from "express";
377
- * import { Sapling } from "@tahminator/sapling";
378
- *
379
- * const app = express();
380
- *
381
- * Sapling.loadResponseStatusErrorMiddleware(app, (err, req, res, next) => {
382
- * // `err` is guaranteed to be of type ResponseStatusError
383
- * res.status(err.status).json({
384
- * success: false,
385
- * message: err.message,
386
- * });
387
- * });
388
- * ```
389
- */
390
- static loadResponseStatusErrorMiddleware(this: void, app: e.Express, fn: (err: ResponseStatusError, request: e.Request, response: e.Response, next: e.NextFunction) => void): void;
391
446
  /**
392
447
  * Serialize a value into a JSON string.
393
448
  *
@@ -418,4 +473,26 @@ declare class Sapling {
418
473
  static setDeserializeFn(this: void, fn: (value: string) => any): void;
419
474
  }
420
475
  //#endregion
421
- export { Class, Controller, DELETE, ExpressMiddlewareFn, ExpressRouterMethodKey, ExpressRouterMethods, GET, HEAD, Html404ErrorPage, HttpHeaders, HttpStatus, Injectable, Middleware, MiddlewareClass, OPTIONS, PATCH, POST, PUT, RedirectView, ResponseEntity, ResponseEntityBuilder, ResponseStatusError, RouteDefinition, Sapling, _ControllerRegistry, _InjectableDeps, _InjectableRegistry, _Route, _getRoutes, _resolve, methodResolve };
476
+ //#region src/helper/error/exception.d.ts
477
+ type ParserErrorLocation = "reqbody" | "reqparams" | "reqquery";
478
+ /**
479
+ * This error should be thrown when some data cannot be parsed by a given schema.
480
+ */
481
+ declare class ParserError extends ResponseStatusError {
482
+ constructor(location: ParserErrorLocation, issues: readonly StandardSchemaV1.Issue[], vendor: string);
483
+ private static formatMessage;
484
+ }
485
+ //#endregion
486
+ //#region src/annotation/request.d.ts
487
+ type RequestSchemaDefinition = {
488
+ body?: StandardSchemaV1;
489
+ param?: StandardSchemaV1;
490
+ query?: StandardSchemaV1;
491
+ };
492
+ declare function RequestBody(schema: StandardSchemaV1): MethodDecorator;
493
+ declare function RequestParam(schema: StandardSchemaV1): MethodDecorator;
494
+ declare function RequestQuery(schema: StandardSchemaV1): MethodDecorator;
495
+ declare function _getRequestSchemas(ctor: Function, fnName: string): RequestSchemaDefinition | undefined;
496
+ declare function _parseOrThrow<TSchema extends StandardSchemaV1>(schema: TSchema, input: unknown, kind: ParserErrorLocation): Promise<StandardSchemaV1.InferOutput<TSchema>>;
497
+ //#endregion
498
+ export { Class, Controller, DELETE, ExpressMiddlewareFn, ExpressRouterMethodKey, ExpressRouterMethods, GET, HEAD, Html404ErrorPage, HttpHeaders, HttpStatus, Injectable, Middleware, MiddlewareClass, OPTIONS, PATCH, POST, PUT, ParserError, ParserErrorLocation, RedirectView, RequestBody, RequestParam, RequestQuery, ResponseEntity, ResponseEntityBuilder, ResponseStatusError, RouteDefinition, Sapling, _ControllerRegistry, _InjectableDeps, _InjectableRegistry, _Route, _getRequestSchemas, _getRoutes, _parseOrThrow, _resolve, methodResolve };
package/dist/index.mjs CHANGED
@@ -197,6 +197,22 @@ var ResponseEntityBuilder = class {
197
197
  }
198
198
  };
199
199
  //#endregion
200
+ //#region src/helper/error/responsestatus.ts
201
+ /**
202
+ * Ensure that you define a middleware that can handle this error.
203
+ *
204
+ * @see {@link Sapling.loadResponseStatusErrorMiddleware}
205
+ */
206
+ var ResponseStatusError = class ResponseStatusError extends Error {
207
+ constructor(status, message) {
208
+ super(message ?? "Something went wrong.");
209
+ this.status = status;
210
+ Object.setPrototypeOf(this, new.target.prototype);
211
+ this.name = `HttpError(${HttpStatus[status]})`;
212
+ if (Error.captureStackTrace) Error.captureStackTrace(this, ResponseStatusError);
213
+ }
214
+ };
215
+ //#endregion
200
216
  //#region src/helper/sapling.ts
201
217
  const settings = {
202
218
  serialize: JSON.stringify,
@@ -270,34 +286,6 @@ var Sapling = class Sapling {
270
286
  app.use(Sapling.json());
271
287
  }
272
288
  /**
273
- * Register a middleware that will handle {@link ResponseStatusError}.
274
- *
275
- * This middleware will chain to the next middleware if it does not catch {@link ResponseStatusError}.
276
- * You may still define middleware to handle all other errors in a separate `app.use` call.
277
- *
278
- * @example
279
- * ```ts
280
- * import express from "express";
281
- * import { Sapling } from "@tahminator/sapling";
282
- *
283
- * const app = express();
284
- *
285
- * Sapling.loadResponseStatusErrorMiddleware(app, (err, req, res, next) => {
286
- * // `err` is guaranteed to be of type ResponseStatusError
287
- * res.status(err.status).json({
288
- * success: false,
289
- * message: err.message,
290
- * });
291
- * });
292
- * ```
293
- */
294
- static loadResponseStatusErrorMiddleware(app, fn) {
295
- app.use(((err, req, res, next) => {
296
- if (err instanceof ResponseStatusError) fn(err, req, res, next);
297
- else next(err);
298
- }));
299
- }
300
- /**
301
289
  * Serialize a value into a JSON string.
302
290
  *
303
291
  * This function is used in {@link ResponseEntity} to serialize the `body`.
@@ -335,22 +323,6 @@ var Sapling = class Sapling {
335
323
  }
336
324
  };
337
325
  //#endregion
338
- //#region src/helper/error.ts
339
- /**
340
- * Ensure that you define a middleware that can handle this error.
341
- *
342
- * @see {@link Sapling.loadResponseStatusErrorMiddleware}
343
- */
344
- var ResponseStatusError = class ResponseStatusError extends Error {
345
- constructor(status, message) {
346
- super(message ?? "Something went wrong.");
347
- this.status = status;
348
- Object.setPrototypeOf(this, new.target.prototype);
349
- this.name = `HttpError(${HttpStatus[status]})`;
350
- if (Error.captureStackTrace) Error.captureStackTrace(this, ResponseStatusError);
351
- }
352
- };
353
- //#endregion
354
326
  //#region src/types.ts
355
327
  const methodResolve = {
356
328
  GET: "get",
@@ -483,6 +455,80 @@ function _resolve(ctor) {
483
455
  return _InjectableRegistry.get(ctor);
484
456
  }
485
457
  //#endregion
458
+ //#region src/helper/error/exception.ts
459
+ /**
460
+ * This error should be thrown when some data cannot be parsed by a given schema.
461
+ */
462
+ var ParserError = class ParserError extends ResponseStatusError {
463
+ constructor(location, issues, vendor) {
464
+ super(400, ParserError.formatMessage(location, issues, vendor));
465
+ Object.setPrototypeOf(this, new.target.prototype);
466
+ }
467
+ static formatMessage(location, issues, vendor) {
468
+ const formatted = issues.map((i) => {
469
+ const path = Array.isArray(i.path) ? i.path.map((seg) => typeof seg === "object" && seg ? String(seg.key) : String(seg)).join(".") : "";
470
+ return path ? `${path}: ${i.message}` : i.message;
471
+ }).join("; ");
472
+ return `${vendor} failed to parse ${(() => {
473
+ switch (location) {
474
+ case "reqbody": return "request body";
475
+ case "reqparams": return "request params";
476
+ case "reqquery": return "request query";
477
+ }
478
+ })()}: ${formatted}`;
479
+ }
480
+ };
481
+ //#endregion
482
+ //#region src/annotation/request.ts
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
+ }
502
+ function RequestBody(schema) {
503
+ return (target, propertyKey) => {
504
+ const ctor = target.constructor;
505
+ const fnName = String(propertyKey);
506
+ _setOnce(_getOrCreateRequestSchemaDefinition(ctor, fnName), "body", schema, fnName);
507
+ };
508
+ }
509
+ function RequestParam(schema) {
510
+ return (target, propertyKey) => {
511
+ const ctor = target.constructor;
512
+ const fnName = String(propertyKey);
513
+ _setOnce(_getOrCreateRequestSchemaDefinition(ctor, fnName), "param", schema, fnName);
514
+ };
515
+ }
516
+ function RequestQuery(schema) {
517
+ return (target, propertyKey) => {
518
+ const ctor = target.constructor;
519
+ const fnName = String(propertyKey);
520
+ _setOnce(_getOrCreateRequestSchemaDefinition(ctor, fnName), "query", schema, fnName);
521
+ };
522
+ }
523
+ function _getRequestSchemas(ctor, fnName) {
524
+ return _requestSchemaStore.get(ctor)?.get(fnName);
525
+ }
526
+ async function _parseOrThrow(schema, input, kind) {
527
+ const result = await schema["~standard"].validate(input);
528
+ if (result.issues) throw new ParserError(kind, result.issues, schema["~standard"].vendor);
529
+ return result.value;
530
+ }
531
+ //#endregion
486
532
  //#region src/annotation/route.ts
487
533
  const _routeStore = /* @__PURE__ */ new WeakMap();
488
534
  /**
@@ -580,6 +626,14 @@ function Controller({ prefix = "", deps = [] } = {}) {
580
626
  const usedRoutes = /* @__PURE__ */ new Set();
581
627
  _InjectableDeps.set(targetClass, deps);
582
628
  const controllerInstance = _resolve(targetClass);
629
+ if (routes.reduce((prev, r) => {
630
+ if (r.method !== "USE") return prev;
631
+ const fn = controllerInstance[r.fnName];
632
+ return typeof fn === "function" && fn.length >= 4 ? prev + 1 : prev;
633
+ }, 0) > 1) throw new Error(`Invalid @MiddlewareClass class "${targetClass.name}":
634
+ Multiple 4-arg @Middleware() error handlers were found.
635
+ Express will not enter routers in error mode, so an error-middleware class must expose exactly one error handler.
636
+ Split these into separate @MiddlewareClass classes, or merge the logic into a single method.`);
583
637
  for (const { method, path, fnName } of routes) {
584
638
  const fn = controllerInstance[fnName];
585
639
  if (typeof fn !== "function") continue;
@@ -588,9 +642,34 @@ function Controller({ prefix = "", deps = [] } = {}) {
588
642
  if (method !== "USE" && usedRoutes.has(routeKey)) throw new Error(`Duplicate route [${method}] "${path instanceof RegExp ? path.source : fp}" detected in controller "${target.name}"`);
589
643
  if (method !== "USE") usedRoutes.add(routeKey);
590
644
  const methodName = methodResolve[method];
645
+ if (method === "USE" && fn.length >= 4) {
646
+ const middlewareFn = async (err, request, response, next) => {
647
+ try {
648
+ const result = fn.bind(controllerInstance)(err, request, response, next);
649
+ if (result instanceof ResponseEntity) {
650
+ response.contentType("application/json").status(result.getStatusCode()).set(result.getHeaders()).send(Sapling.serialize(result.getBody()));
651
+ return;
652
+ }
653
+ if (result instanceof RedirectView) {
654
+ response.redirect(result.getUrl());
655
+ return;
656
+ }
657
+ } catch (e) {
658
+ console.error(e);
659
+ next(e);
660
+ }
661
+ };
662
+ _ControllerRegistry.set(targetClass, middlewareFn);
663
+ return;
664
+ }
591
665
  router[methodName](fp, async (request, response, next) => {
666
+ const schemas = _getRequestSchemas(target, fnName);
667
+ if (schemas) {
668
+ if (schemas.body) request.body = await _parseOrThrow(schemas.body, request.body, "reqbody");
669
+ if (schemas.param) request.params = await _parseOrThrow(schemas.param, request.params, "reqparams");
670
+ if (schemas.query) request.query = await _parseOrThrow(schemas.query, request.query, "reqquery");
671
+ }
592
672
  const result = await fn.bind(controllerInstance)(request, response, next);
593
- if (method === "USE") return;
594
673
  if (result instanceof ResponseEntity) {
595
674
  response.contentType("application/json").status(result.getStatusCode()).set(result.getHeaders()).send(Sapling.serialize(result.getBody()));
596
675
  return;
@@ -599,7 +678,7 @@ function Controller({ prefix = "", deps = [] } = {}) {
599
678
  response.redirect(result.getUrl());
600
679
  return;
601
680
  }
602
- if (!response.writableEnded) response.status(404).send(Html404ErrorPage(`Cannot ${methodName.toUpperCase()} ${path instanceof RegExp ? path.source : fp}`));
681
+ if (method !== "USE" && !response.writableEnded) response.status(404).send(Html404ErrorPage(`Cannot ${methodName.toUpperCase()} ${path instanceof RegExp ? path.source : fp}`));
603
682
  });
604
683
  }
605
684
  _ControllerRegistry.set(targetClass, router);
@@ -618,4 +697,4 @@ function MiddlewareClass(...args) {
618
697
  return Controller(...args);
619
698
  }
620
699
  //#endregion
621
- export { Controller, DELETE, GET, HEAD, Html404ErrorPage, HttpStatus, Injectable, Middleware, MiddlewareClass, OPTIONS, PATCH, POST, PUT, RedirectView, ResponseEntity, ResponseEntityBuilder, ResponseStatusError, Sapling, _ControllerRegistry, _InjectableDeps, _InjectableRegistry, _Route, _getRoutes, _resolve, methodResolve };
700
+ export { Controller, DELETE, GET, HEAD, Html404ErrorPage, HttpStatus, Injectable, Middleware, MiddlewareClass, OPTIONS, PATCH, POST, PUT, ParserError, RedirectView, RequestBody, RequestParam, RequestQuery, ResponseEntity, ResponseEntityBuilder, ResponseStatusError, Sapling, _ControllerRegistry, _InjectableDeps, _InjectableRegistry, _Route, _getRequestSchemas, _getRoutes, _parseOrThrow, _resolve, methodResolve };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tahminator/sapling",
3
- "version": "1.5.28",
3
+ "version": "2.0.0",
4
4
  "author": "Tahmid Ahmed",
5
5
  "description": "A library to help you write cleaner Express.js code",
6
6
  "repository": {
@@ -41,6 +41,7 @@
41
41
  },
42
42
  "devDependencies": {
43
43
  "@eslint/js": "^10.0.1",
44
+ "@standard-schema/spec": "^1.1.0",
44
45
  "@types/express": "^5",
45
46
  "@types/supertest": "^7.2.0",
46
47
  "@vitest/coverage-istanbul": "^4.1.2",
@@ -55,6 +56,10 @@
55
56
  "tsdown": "^0.21.10",
56
57
  "typescript-eslint": "^8.57.2",
57
58
  "vite-tsconfig-paths": "^6.1.1",
58
- "vitest": "^4.1.2"
59
+ "vitest": "^4.1.2",
60
+ "zod": "^4.4.3"
61
+ },
62
+ "inlinedDependencies": {
63
+ "@standard-schema/spec": "1.1.0"
59
64
  }
60
65
  }