@tahminator/sapling 2.0.4 → 2.0.5-beta.2f539758

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
@@ -264,7 +264,8 @@ var ParserError = class ParserError extends ResponseStatusError {
264
264
  //#region src/helper/sapling.ts
265
265
  const settings = {
266
266
  serialize: JSON.stringify,
267
- deserialize: JSON.parse
267
+ deserialize: JSON.parse,
268
+ openapi: { path: "/openapi.json" }
268
269
  };
269
270
  /**
270
271
  * Collection of utility functions which are essential for Sapling to function.
@@ -369,140 +370,11 @@ var Sapling = class Sapling {
369
370
  static setDeserializeFn(fn) {
370
371
  settings.deserialize = fn;
371
372
  }
372
- };
373
- //#endregion
374
- //#region src/types.ts
375
- const methodResolve = {
376
- GET: "get",
377
- PUT: "put",
378
- POST: "post",
379
- DELETE: "delete",
380
- OPTIONS: "options",
381
- PATCH: "patch",
382
- HEAD: "head",
383
- USE: "use"
384
- };
385
- //#endregion
386
- //#region lib/weakmap.ts
387
- /**
388
- * WeakMap that is iterable.
389
- */
390
- var IterableWeakMap = class IterableWeakMap {
391
- #weakMap = /* @__PURE__ */ new WeakMap();
392
- #refSet = /* @__PURE__ */ new Set();
393
- #finalizationGroup = new FinalizationRegistry(IterableWeakMap.#cleanup);
394
- static #cleanup(heldValue) {
395
- heldValue.set.delete(heldValue.ref);
396
- }
397
- constructor(iterable) {
398
- if (iterable) for (const [key, value] of iterable) this.set(key, value);
399
- }
400
- set(key, value) {
401
- const ref = new WeakRef(key);
402
- this.#weakMap.set(key, {
403
- value,
404
- ref
405
- });
406
- this.#refSet.add(ref);
407
- this.#finalizationGroup.register(key, {
408
- set: this.#refSet,
409
- ref
410
- }, ref);
411
- return this;
412
- }
413
- get(key) {
414
- return this.#weakMap.get(key)?.value;
415
- }
416
- delete(key) {
417
- const entry = this.#weakMap.get(key);
418
- if (!entry) return false;
419
- this.#weakMap.delete(key);
420
- this.#refSet.delete(entry.ref);
421
- this.#finalizationGroup.unregister(entry.ref);
422
- return true;
423
- }
424
- *[Symbol.iterator]() {
425
- for (const ref of this.#refSet) {
426
- const key = ref.deref();
427
- if (!key) continue;
428
- const entry = this.#weakMap.get(key);
429
- if (entry) yield [key, entry.value];
430
- }
431
- }
432
- entries() {
433
- return this[Symbol.iterator]();
434
- }
435
- *keys() {
436
- for (const [key] of this) yield key;
437
- }
438
- *values() {
439
- for (const [, value] of this) yield value;
440
- }
441
- forEach(callback, thisArg) {
442
- for (const [key, value] of this) callback.call(thisArg, value, key, this);
373
+ static setOpenApiPath(path) {
374
+ settings.openapi.path = path;
443
375
  }
444
376
  };
445
377
  //#endregion
446
- //#region src/annotation/injectable.ts
447
- const _InjectableRegistry = /* @__PURE__ */ new WeakMap();
448
- const _InjectableDeps = new IterableWeakMap();
449
- /**
450
- * Mark the class as an injectable to be handled by Sapling. The class can now be
451
- * be injected into other classes, as well as allow the class to inject other `@Injectable` classes.
452
- *
453
- * @argument deps - An optional array to define any dependencies that this class may require.
454
- */
455
- function Injectable(deps = []) {
456
- return function(target) {
457
- _InjectableRegistry.set(target, null);
458
- _InjectableDeps.set(target, deps);
459
- };
460
- }
461
- /**
462
- * Resolves and instantiates a class along with all of it's transitive dependencies.
463
- *
464
- * Uses topological sort (Kahn's algorithm) to ensure that the dependency graph is created
465
- * in a correct order.
466
- *
467
- * When `resolve` is first called (usually during controller registration),
468
- * it will compute the dependency graph of all `@Injectable` classes and instantiates
469
- * them in the correct order.
470
- *
471
- * Subsequent calls to dependencies that have already been resolved are cached, so they will
472
- * re-use the created singletons instead of re-instantiation.
473
- */
474
- function _resolve(ctor) {
475
- const inDegree = /* @__PURE__ */ new Map();
476
- const graph = /* @__PURE__ */ new Map();
477
- _InjectableDeps.forEach((deps, node) => {
478
- inDegree.set(node, inDegree.get(node) || 0);
479
- deps.forEach((dep) => {
480
- if (dep === void 0) throw new Error(`There is an @Injectable (${node.name}) which has a dependency that cannot be found. This is likely caused by a circular dependency.`);
481
- inDegree.set(dep, inDegree.get(dep) || 0);
482
- inDegree.set(node, inDegree.get(node) + 1);
483
- if (!graph.has(dep)) graph.set(dep, []);
484
- graph.get(dep).push(node);
485
- });
486
- });
487
- const queue = [];
488
- inDegree.forEach((deg, node) => {
489
- if (deg === 0) queue.push(node);
490
- });
491
- while (queue.length) {
492
- const current = queue.shift();
493
- if (!_InjectableRegistry.get(current)) {
494
- const instance = new current(...(_InjectableDeps.get(current) || []).map((dep) => _InjectableRegistry.get(dep)));
495
- _InjectableRegistry.set(current, instance);
496
- }
497
- (graph.get(current) || []).forEach((neighbor) => {
498
- inDegree.set(neighbor, (inDegree.get(neighbor) ?? 0) - 1);
499
- if (inDegree.get(neighbor) === 0) queue.push(neighbor);
500
- });
501
- }
502
- if (!_InjectableRegistry.get(ctor)) throw new Error("Circular dependency detected or injectable not registered");
503
- return _InjectableRegistry.get(ctor);
504
- }
505
- //#endregion
506
378
  //#region src/annotation/request.ts
507
379
  const _requestSchemaStore = /* @__PURE__ */ new WeakMap();
508
380
  /**
@@ -711,6 +583,224 @@ function _getRoutes(ctor) {
711
583
  return _routeStore.get(ctor) ?? [];
712
584
  }
713
585
  //#endregion
586
+ //#region src/helper/openapi.ts
587
+ var OpenAPIGenerator = class {
588
+ constructor() {
589
+ this.controllers = /* @__PURE__ */ new Set();
590
+ this.config = {
591
+ title: "API",
592
+ version: "1.0.0"
593
+ };
594
+ }
595
+ setConfig(config) {
596
+ this.config = config;
597
+ }
598
+ registerController(controllerClass, prefix) {
599
+ this.controllers.add({
600
+ class: controllerClass,
601
+ prefix
602
+ });
603
+ }
604
+ generateSpec() {
605
+ const config = this.config;
606
+ const paths = {};
607
+ for (const { class: controllerClass, prefix } of this.controllers) {
608
+ const routes = _getRoutes(controllerClass);
609
+ for (const route of routes) {
610
+ if (route.method === "USE") continue;
611
+ const schemas = _getRequestSchemas(controllerClass, route.fnName);
612
+ const fullPath = route.path instanceof RegExp ? route.path.source : prefix + route.path;
613
+ const openApiPath = typeof fullPath === "string" ? fullPath.replace(/:(\w+)/g, "{$1}") : fullPath;
614
+ if (!paths[openApiPath]) paths[openApiPath] = {};
615
+ const operation = { responses: { "200": { description: "Successful response" } } };
616
+ const parameters = [];
617
+ if (schemas?.param) {
618
+ const paramSchema = this.toJsonSchema(schemas.param);
619
+ if (paramSchema.type === "object" && paramSchema.properties) for (const [name, schema] of Object.entries(paramSchema.properties)) parameters.push({
620
+ name,
621
+ in: "path",
622
+ required: true,
623
+ schema
624
+ });
625
+ }
626
+ if (schemas?.query) {
627
+ const querySchema = this.toJsonSchema(schemas.query);
628
+ if (querySchema.type === "object" && querySchema.properties) for (const [name, schema] of Object.entries(querySchema.properties)) {
629
+ const isRequired = Array.isArray(querySchema.required) && querySchema.required.includes(name);
630
+ parameters.push({
631
+ name,
632
+ in: "query",
633
+ required: isRequired,
634
+ schema
635
+ });
636
+ }
637
+ }
638
+ if (parameters.length > 0) operation.parameters = parameters;
639
+ if (schemas?.body) operation.requestBody = {
640
+ required: true,
641
+ content: { "application/json": { schema: this.toJsonSchema(schemas.body) } }
642
+ };
643
+ const method = route.method.toLowerCase();
644
+ paths[openApiPath][method] = operation;
645
+ }
646
+ }
647
+ return {
648
+ openapi: "3.0.0",
649
+ info: {
650
+ title: config.title,
651
+ version: config.version,
652
+ description: config.description
653
+ },
654
+ paths
655
+ };
656
+ }
657
+ toJsonSchema(schema) {
658
+ return schema["~standard"].jsonSchema.output({ target: "openapi-3.0" });
659
+ }
660
+ };
661
+ const openApiGenerator = new OpenAPIGenerator();
662
+ function _registerControllerClass(controllerClass, prefix) {
663
+ openApiGenerator.registerController(controllerClass, prefix);
664
+ }
665
+ function setOpenApiConfig(config) {
666
+ openApiGenerator.setConfig(config);
667
+ }
668
+ function generateOpenApiSpec() {
669
+ return openApiGenerator.generateSpec();
670
+ }
671
+ //#endregion
672
+ //#region src/types.ts
673
+ const methodResolve = {
674
+ GET: "get",
675
+ PUT: "put",
676
+ POST: "post",
677
+ DELETE: "delete",
678
+ OPTIONS: "options",
679
+ PATCH: "patch",
680
+ HEAD: "head",
681
+ USE: "use"
682
+ };
683
+ //#endregion
684
+ //#region lib/weakmap.ts
685
+ /**
686
+ * WeakMap that is iterable.
687
+ */
688
+ var IterableWeakMap = class IterableWeakMap {
689
+ #weakMap = /* @__PURE__ */ new WeakMap();
690
+ #refSet = /* @__PURE__ */ new Set();
691
+ #finalizationGroup = new FinalizationRegistry(IterableWeakMap.#cleanup);
692
+ static #cleanup(heldValue) {
693
+ heldValue.set.delete(heldValue.ref);
694
+ }
695
+ constructor(iterable) {
696
+ if (iterable) for (const [key, value] of iterable) this.set(key, value);
697
+ }
698
+ set(key, value) {
699
+ const ref = new WeakRef(key);
700
+ this.#weakMap.set(key, {
701
+ value,
702
+ ref
703
+ });
704
+ this.#refSet.add(ref);
705
+ this.#finalizationGroup.register(key, {
706
+ set: this.#refSet,
707
+ ref
708
+ }, ref);
709
+ return this;
710
+ }
711
+ get(key) {
712
+ return this.#weakMap.get(key)?.value;
713
+ }
714
+ delete(key) {
715
+ const entry = this.#weakMap.get(key);
716
+ if (!entry) return false;
717
+ this.#weakMap.delete(key);
718
+ this.#refSet.delete(entry.ref);
719
+ this.#finalizationGroup.unregister(entry.ref);
720
+ return true;
721
+ }
722
+ *[Symbol.iterator]() {
723
+ for (const ref of this.#refSet) {
724
+ const key = ref.deref();
725
+ if (!key) continue;
726
+ const entry = this.#weakMap.get(key);
727
+ if (entry) yield [key, entry.value];
728
+ }
729
+ }
730
+ entries() {
731
+ return this[Symbol.iterator]();
732
+ }
733
+ *keys() {
734
+ for (const [key] of this) yield key;
735
+ }
736
+ *values() {
737
+ for (const [, value] of this) yield value;
738
+ }
739
+ forEach(callback, thisArg) {
740
+ for (const [key, value] of this) callback.call(thisArg, value, key, this);
741
+ }
742
+ };
743
+ //#endregion
744
+ //#region src/annotation/injectable.ts
745
+ const _InjectableRegistry = /* @__PURE__ */ new WeakMap();
746
+ const _InjectableDeps = new IterableWeakMap();
747
+ /**
748
+ * Mark the class as an injectable to be handled by Sapling. The class can now be
749
+ * be injected into other classes, as well as allow the class to inject other `@Injectable` classes.
750
+ *
751
+ * @argument deps - An optional array to define any dependencies that this class may require.
752
+ */
753
+ function Injectable(deps = []) {
754
+ return function(target) {
755
+ _InjectableRegistry.set(target, null);
756
+ _InjectableDeps.set(target, deps);
757
+ };
758
+ }
759
+ /**
760
+ * Resolves and instantiates a class along with all of it's transitive dependencies.
761
+ *
762
+ * Uses topological sort (Kahn's algorithm) to ensure that the dependency graph is created
763
+ * in a correct order.
764
+ *
765
+ * When `resolve` is first called (usually during controller registration),
766
+ * it will compute the dependency graph of all `@Injectable` classes and instantiates
767
+ * them in the correct order.
768
+ *
769
+ * Subsequent calls to dependencies that have already been resolved are cached, so they will
770
+ * re-use the created singletons instead of re-instantiation.
771
+ */
772
+ function _resolve(ctor) {
773
+ const inDegree = /* @__PURE__ */ new Map();
774
+ const graph = /* @__PURE__ */ new Map();
775
+ _InjectableDeps.forEach((deps, node) => {
776
+ inDegree.set(node, inDegree.get(node) || 0);
777
+ deps.forEach((dep) => {
778
+ if (dep === void 0) throw new Error(`There is an @Injectable (${node.name}) which has a dependency that cannot be found. This is likely caused by a circular dependency.`);
779
+ inDegree.set(dep, inDegree.get(dep) || 0);
780
+ inDegree.set(node, inDegree.get(node) + 1);
781
+ if (!graph.has(dep)) graph.set(dep, []);
782
+ graph.get(dep).push(node);
783
+ });
784
+ });
785
+ const queue = [];
786
+ inDegree.forEach((deg, node) => {
787
+ if (deg === 0) queue.push(node);
788
+ });
789
+ while (queue.length) {
790
+ const current = queue.shift();
791
+ if (!_InjectableRegistry.get(current)) {
792
+ const instance = new current(...(_InjectableDeps.get(current) || []).map((dep) => _InjectableRegistry.get(dep)));
793
+ _InjectableRegistry.set(current, instance);
794
+ }
795
+ (graph.get(current) || []).forEach((neighbor) => {
796
+ inDegree.set(neighbor, (inDegree.get(neighbor) ?? 0) - 1);
797
+ if (inDegree.get(neighbor) === 0) queue.push(neighbor);
798
+ });
799
+ }
800
+ if (!_InjectableRegistry.get(ctor)) throw new Error("Circular dependency detected or injectable not registered");
801
+ return _InjectableRegistry.get(ctor);
802
+ }
803
+ //#endregion
714
804
  //#region src/annotation/controller.ts
715
805
  const _ControllerRegistry = /* @__PURE__ */ new WeakMap();
716
806
  /**
@@ -722,6 +812,7 @@ const _ControllerRegistry = /* @__PURE__ */ new WeakMap();
722
812
  function Controller({ prefix = "", deps = [] } = {}) {
723
813
  return (target) => {
724
814
  const targetClass = target;
815
+ _registerControllerClass(target, prefix);
725
816
  const router = (0, express.Router)();
726
817
  const routes = _getRoutes(target);
727
818
  const usedRoutes = /* @__PURE__ */ new Set();
@@ -813,7 +904,7 @@ function __decorate(decorators, target, key, desc) {
813
904
  return c > 3 && r && Object.defineProperty(target, key, r), r;
814
905
  }
815
906
  //#endregion
816
- //#region src/middleware/default/base.ts
907
+ //#region src/middleware/default/error/base.ts
817
908
  let DefaultBaseErrorMiddleware = class DefaultBaseErrorMiddleware {
818
909
  handle(err, _request, _response, _next) {
819
910
  console.error("[Error]", err);
@@ -823,7 +914,17 @@ let DefaultBaseErrorMiddleware = class DefaultBaseErrorMiddleware {
823
914
  __decorate([Middleware()], DefaultBaseErrorMiddleware.prototype, "handle", null);
824
915
  DefaultBaseErrorMiddleware = __decorate([MiddlewareClass()], DefaultBaseErrorMiddleware);
825
916
  //#endregion
826
- //#region src/middleware/default/responsestatus.ts
917
+ //#region src/middleware/default/error/parse.ts
918
+ let DefaultParserErrorMiddleware = class DefaultParserErrorMiddleware {
919
+ handle(err, _request, _response, next) {
920
+ if (err instanceof ParserError) return ResponseEntity.status(err.status).body({ message: err.message });
921
+ next(err);
922
+ }
923
+ };
924
+ __decorate([Middleware()], DefaultParserErrorMiddleware.prototype, "handle", null);
925
+ DefaultParserErrorMiddleware = __decorate([MiddlewareClass()], DefaultParserErrorMiddleware);
926
+ //#endregion
927
+ //#region src/middleware/default/error/responsestatus.ts
827
928
  let DefaultResponseStatusErrorMiddleware = class DefaultResponseStatusErrorMiddleware {
828
929
  handle(err, _request, _response, next) {
829
930
  if (err instanceof ResponseStatusError) return ResponseEntity.status(err.status).body({ message: err.message });
@@ -833,6 +934,15 @@ let DefaultResponseStatusErrorMiddleware = class DefaultResponseStatusErrorMiddl
833
934
  __decorate([Middleware()], DefaultResponseStatusErrorMiddleware.prototype, "handle", null);
834
935
  DefaultResponseStatusErrorMiddleware = __decorate([MiddlewareClass()], DefaultResponseStatusErrorMiddleware);
835
936
  //#endregion
937
+ //#region src/middleware/default/openapi/index.ts
938
+ let DefaultOpenApiMiddleware = class DefaultOpenApiMiddleware {
939
+ handle(_request, _response, _next) {
940
+ return ResponseEntity.ok().body(generateOpenApiSpec());
941
+ }
942
+ };
943
+ __decorate([GET("/openapi.json")], DefaultOpenApiMiddleware.prototype, "handle", null);
944
+ DefaultOpenApiMiddleware = __decorate([MiddlewareClass()], DefaultOpenApiMiddleware);
945
+ //#endregion
836
946
  exports.Controller = Controller;
837
947
  exports.DELETE = DELETE;
838
948
  Object.defineProperty(exports, "DefaultBaseErrorMiddleware", {
@@ -841,6 +951,18 @@ Object.defineProperty(exports, "DefaultBaseErrorMiddleware", {
841
951
  return DefaultBaseErrorMiddleware;
842
952
  }
843
953
  });
954
+ Object.defineProperty(exports, "DefaultOpenApiMiddleware", {
955
+ enumerable: true,
956
+ get: function() {
957
+ return DefaultOpenApiMiddleware;
958
+ }
959
+ });
960
+ Object.defineProperty(exports, "DefaultParserErrorMiddleware", {
961
+ enumerable: true,
962
+ get: function() {
963
+ return DefaultParserErrorMiddleware;
964
+ }
965
+ });
844
966
  Object.defineProperty(exports, "DefaultResponseStatusErrorMiddleware", {
845
967
  enumerable: true,
846
968
  get: function() {
@@ -874,5 +996,9 @@ exports._Route = _Route;
874
996
  exports._getRequestSchemas = _getRequestSchemas;
875
997
  exports._getRoutes = _getRoutes;
876
998
  exports._parseOrThrow = _parseOrThrow;
999
+ exports._registerControllerClass = _registerControllerClass;
877
1000
  exports._resolve = _resolve;
1001
+ exports.generateOpenApiSpec = generateOpenApiSpec;
878
1002
  exports.methodResolve = methodResolve;
1003
+ exports.openApiGenerator = openApiGenerator;
1004
+ exports.setOpenApiConfig = setOpenApiConfig;