@tahminator/sapling 2.0.4 → 2.0.5-beta.1843d6fa

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,9 +19,13 @@ 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
+ * [Request/Response Validation](#requestresponse-validation)
23
23
  * [Redirects](#redirects)
24
24
  * [Dependency Injection](#dependency-injection)
25
+ * [OpenAPI Support](#openapi-support)
26
+ + [Automatic Spec Generation](#automatic-spec-generation)
27
+ + [Adding Documentation with Decorators](#adding-documentation-with-decorators)
28
+ + [Customizing Paths](#customizing-paths)
25
29
  * [Custom Serialization](#custom-serialization)
26
30
  - [Advanced Setup](#advanced-setup)
27
31
  * [Automatically import controllers](#automatically-import-controllers)
@@ -107,6 +111,16 @@ middlewares.map(Sapling.resolve).forEach((r) => app.use(r));
107
111
  const controllers: Class<any>[] = [HelloController, UserController];
108
112
  controllers.map(Sapling.resolve).forEach((r) => app.use(r));
109
113
 
114
+ // @MiddlewareClass handling custom errors should be handled after everything else.
115
+ // Sapling includes basic `DefaultResponseStatusErrorMiddleware` & `BaseErrorMiddleware`
116
+ // but you may write your own, even to replace these defaults!
117
+ const errorMiddlewares: Class<any>[] = [
118
+ DefaultParserErrorMiddleware,
119
+ DefaultResponseStatusErrorMiddleware,
120
+ DefaultBaseErrorMiddleware,
121
+ ];
122
+ errorMiddlewares.map(Sapling.resolve).forEach((r) => app.use(r));
123
+
110
124
  app.listen(3000);
111
125
  ```
112
126
 
@@ -142,12 +156,9 @@ Sapling supports the usual suspects:
142
156
  - `@PUT(path?)`
143
157
  - `@DELETE(path?)`
144
158
  - `@PATCH(path?)`
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
159
+ - `@Middleware(path?)` (alias of `@USE(path?)`) - for middleware
149
160
 
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).
161
+ Path defaults to `"/"` if you don't pass one.
151
162
 
152
163
  ### Responses
153
164
 
@@ -200,12 +211,72 @@ class UserController {
200
211
  }
201
212
  ```
202
213
 
203
- Make sure to register an error handler middleware:
214
+ Sapling ships with default error middlewares, and you can also write your own.
215
+ Register error middlewares after your regular middlewares and controllers:
204
216
 
205
217
  ```typescript
206
- Sapling.loadResponseStatusErrorMiddleware(app, (err, req, res, next) => {
207
- res.status(err.status).json({ error: err.message });
208
- });
218
+ import {
219
+ DefaultBaseErrorMiddleware,
220
+ DefaultResponseStatusErrorMiddleware,
221
+ } from "@tahminator/sapling";
222
+
223
+ // regular middlewares & controllers first
224
+ const middlewares: Class<any>[] = [CookieParserMiddleware];
225
+ middlewares.map(Sapling.resolve).forEach((r) => app.use(r));
226
+
227
+ const controllers: Class<any>[] = [UserController];
228
+ controllers.map(Sapling.resolve).forEach((r) => app.use(r));
229
+
230
+ // error middlewares last
231
+ const errorMiddlewares: Class<any>[] = [
232
+ DefaultParserErrorMiddleware,
233
+ DefaultResponseStatusErrorMiddleware,
234
+ DefaultBaseErrorMiddleware,
235
+ ];
236
+ errorMiddlewares.map(Sapling.resolve).forEach((r) => app.use(r));
237
+ ```
238
+
239
+ You can also write your own error middlewares. A specific handler should call
240
+ `next(err)` when it does not handle the error, and a base handler should be last
241
+ and return a response:
242
+
243
+ ```typescript
244
+ @MiddlewareClass()
245
+ class ResponseStatusErrorMiddleware {
246
+ @Middleware()
247
+ handle(
248
+ err: unknown,
249
+ _request: Request,
250
+ _response: Response,
251
+ next: NextFunction,
252
+ ) {
253
+ if (err instanceof ResponseStatusError) {
254
+ return ResponseEntity.status(err.status).body({ message: err.message });
255
+ }
256
+
257
+ // MUST call next(err) to continue the chain
258
+ next(err);
259
+ }
260
+ }
261
+
262
+ @MiddlewareClass()
263
+ class BaseErrorMiddleware {
264
+ @Middleware()
265
+ handle(
266
+ err: unknown,
267
+ _request: Request,
268
+ _response: Response,
269
+ _next: NextFunction,
270
+ ) {
271
+ console.error("[Error]", err);
272
+
273
+ return ResponseEntity.status(500).body({
274
+ message: "Internal Server Error",
275
+ });
276
+
277
+ // no next(err) since last middleware in chain, we are done propagating
278
+ }
279
+ }
209
280
  ```
210
281
 
211
282
  ### Middleware
@@ -234,15 +305,53 @@ class CookieParserMiddleware {
234
305
  // Register it like any controller
235
306
  app.use(Sapling.resolve(CookieParserMiddleware));
236
307
 
308
+ // Register middlewares before controllers
309
+ app.use(Sapling.resolve(UserController));
310
+
237
311
  // You can also still choose to load plugins the Express.js way
238
312
  app.use(cookieParser());
239
313
  ```
240
314
 
241
- ### Request Validation
315
+ You can also write custom middlewares as well. It is functionally the same way as Express: call `next()` explicitly to
316
+ continue down the chain:
317
+
318
+ ```typescript
319
+ import { MiddlewareClass, Middleware } from "@tahminator/sapling";
320
+ import { NextFunction, Request, Response } from "express";
321
+
322
+ @MiddlewareClass()
323
+ class RequestTimerMiddleware {
324
+ @Middleware()
325
+ handle(request: Request, _response: Response, next: NextFunction) {
326
+ const start = Date.now();
327
+
328
+ request.on("finish", () => {
329
+ const elapsedMs = Date.now() - start;
330
+ console.log(`[Request] ${request.method} ${request.path} ${elapsedMs}ms`);
331
+ });
332
+
333
+ // MUST call next() to continue the chain
334
+ next();
335
+ }
336
+ }
337
+
338
+ // Register middlewares before controllers
339
+ app.use(Sapling.resolve(RequestTimerMiddleware));
340
+ app.use(Sapling.resolve(UserController));
341
+ ```
342
+
343
+ ### Request/Response Validation
242
344
 
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.).
345
+ Validate and transform request request & response inputs / outputs.
244
346
 
245
- If validation fails, a `ParserError` is thrown, which Express handles as a `400 Bad Request` by default:
347
+ - `@RequestBody(schema)` - validate & parse the request body
348
+ - `@RequestParam(schema)` - validate & parse route params
349
+ - `@RequestQuery(schema)` - validate & parse the query string
350
+ - `@ResponseBody(schema)` - validate & parse the response body
351
+
352
+ These decorators accept any [Standard Schema](https://github.com/standard-schema/standard-schema) compatible validation library (Zod, Valibot, ArkType, etc.).
353
+
354
+ If validation fails, a `ParserError` is thrown. You may register `DefaultParserErrorMiddleware` for default error handling or write your own:
246
355
 
247
356
  ```typescript
248
357
  import { z } from "zod";
@@ -250,6 +359,7 @@ import { z } from "zod";
250
359
  const CreateUserSchema = z.object({ name: z.string(), age: z.number() });
251
360
  const UserParamsSchema = z.object({ id: z.string() });
252
361
  const ListUsersQuerySchema = z.object({ page: z.coerce.number() });
362
+ const ListUserIdsResponseSchema = z.array(z.object({ id: z.int() }));
253
363
 
254
364
  @Controller({ prefix: "/users" })
255
365
  class UserController {
@@ -276,14 +386,15 @@ class UserController {
276
386
  }
277
387
 
278
388
  @RequestQuery(ListUsersQuerySchema)
389
+ @ResponseBody(ListUserIdsResponseSchema) // since we have this annotation, it will a) strip all extra fields & b) throw if we are missing any expected fields.
279
390
  @GET()
280
- listUsers(request: Request): ResponseEntity<z.infer<typeof ListUsersQuerySchema>> {
391
+ listUserIds(request: Request): ResponseEntity<z.infer<typeof ListUserIdsResponseSchema>> {
281
392
  // request.query has been fully validated and rewritten. you can safely assert the type!
282
393
  const query = request.query as unknown as z.infer<typeof ListUsersQuerySchema>;
283
394
 
284
- const users = this.database.user.findAll({ page: query.page });
395
+ const userIds = this.database.user.findAll({ page: query.page }).map(u => u.id);
285
396
 
286
- return ResponseEntity.ok().body(users);
397
+ return ResponseEntity.ok().body(userIds);
287
398
  }
288
399
  }
289
400
  ```
@@ -342,6 +453,134 @@ class UserRepository {
342
453
  }
343
454
  ```
344
455
 
456
+ ### OpenAPI Support
457
+
458
+ Sapling automatically generates OpenAPI 3.0 specifications from your controllers and serves them with Swagger UI.
459
+
460
+ #### Automatic Spec Generation
461
+
462
+ Configure your API metadata and serve the OpenAPI spec:
463
+
464
+ ```typescript
465
+ import {
466
+ Sapling,
467
+ DefaultOpenApiMiddleware,
468
+ DefaultSwaggerMiddleware,
469
+ } from "@tahminator/sapling";
470
+
471
+ // Configure API metadata
472
+ Sapling.Extras.swaggerAndOpenApi.setMetadata({
473
+ title: "My API",
474
+ version: "1.0.0",
475
+ description: "API documentation for my application",
476
+ });
477
+
478
+ // Serve OpenAPI spec at /openapi.json and Swagger UI at /swagger.html
479
+ // modify default routes with `Sapling.Extras.swaggerAndOpenApi`
480
+ const middlewares = [
481
+ DefaultOpenApiMiddleware,
482
+ DefaultSwaggerMiddleware.Serve,
483
+ DefaultSwaggerMiddleware.Setup,
484
+ ];
485
+ middlewares.map(Sapling.resolve).forEach((r) => app.use(r));
486
+
487
+ // Register your controllers after the OpenAPI middlewares
488
+ const controllers = [BaseController, UserController];
489
+ controllers.map(Sapling.resolve).forEach((r) => app.use(r));
490
+ ```
491
+
492
+ The OpenAPI spec is automatically generated from your `@RequestBody`, `@RequestParam`, `@RequestQuery`, and `@ResponseBody` decorators.
493
+
494
+ #### Adding Documentation with Decorators
495
+
496
+ Use `@ControllerSchema` and `@RouteSchema` to add rich documentation:
497
+
498
+ ```typescript
499
+ import {
500
+ Controller,
501
+ ControllerSchema,
502
+ RouteSchema,
503
+ GET,
504
+ POST,
505
+ RequestBody,
506
+ ResponseBody,
507
+ HttpStatus,
508
+ } from "@tahminator/sapling";
509
+ import { z } from "zod";
510
+
511
+ const UserSchema = z.object({
512
+ id: z.string().describe("User ID"),
513
+ name: z.string().describe("User's full name"),
514
+ email: z.string().email().describe("User's email address"),
515
+ });
516
+
517
+ const CreateUserSchema = z.object({
518
+ name: z.string().min(1).describe("User's full name"),
519
+ email: z.string().email().describe("User's email address"),
520
+ });
521
+
522
+ const ErrorSchema = z.object({
523
+ message: z.string().describe("Error message"),
524
+ });
525
+
526
+ @ControllerSchema({
527
+ title: "Users",
528
+ description: "User management endpoints",
529
+ })
530
+ @Controller({ prefix: "/users" })
531
+ class UserController {
532
+ @RouteSchema({
533
+ summary: "List all users",
534
+ description: "Returns a paginated list of all users in the system",
535
+ responses: [
536
+ {
537
+ statusCode: HttpStatus.OK,
538
+ description: "List of users",
539
+ schema: z.array(UserSchema),
540
+ },
541
+ ],
542
+ })
543
+ @ResponseBody(z.array(UserSchema))
544
+ @GET()
545
+ listUsers(): ResponseEntity<z.infer<typeof UserSchema>[]> {
546
+ return ResponseEntity.ok().body([...]);
547
+ }
548
+
549
+ @RouteSchema({
550
+ summary: "Create a new user",
551
+ description: "Creates a new user with the provided information",
552
+ responses: [
553
+ {
554
+ statusCode: HttpStatus.CREATED,
555
+ description: "User created successfully",
556
+ schema: UserSchema,
557
+ },
558
+ {
559
+ statusCode: HttpStatus.BAD_REQUEST,
560
+ description: "Invalid request data",
561
+ schema: ErrorSchema,
562
+ },
563
+ ],
564
+ })
565
+ @RequestBody(CreateUserSchema)
566
+ @ResponseBody(UserSchema)
567
+ @POST()
568
+ createUser(request: Request): ResponseEntity<z.infer<typeof UserSchema>> {
569
+ const body = request.body as z.infer<typeof CreateUserSchema>;
570
+ return ResponseEntity.status(HttpStatus.CREATED).body({...});
571
+ }
572
+ }
573
+ ```
574
+
575
+ #### Customizing Paths
576
+
577
+ By default, OpenAPI spec is served at `/openapi.json` and Swagger UI at `/swagger.html`. Customize these paths:
578
+
579
+ ```typescript
580
+ Sapling.Extras.swaggerAndOpenApi.setOpenApiPath("/api-spec.json");
581
+ Sapling.Extras.swaggerAndOpenApi.setSwaggerPath("/api-docs");
582
+ ```
583
+
345
584
  ### Custom Serialization
346
585
 
347
586
  By default, Sapling uses `JSON.stringify` and `JSON.parse` for serialization. You can override these with custom serializers like [superjson](https://github.com/flightcontrolhq/superjson#readme) to automatically handle Dates, BigInts, and more: