@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 +255 -16
- package/dist/index.cjs +591 -262
- package/dist/index.d.cts +589 -88
- package/dist/index.d.mts +589 -88
- package/dist/index.mjs +563 -262
- package/package.json +8 -2
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](#
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
|
345
|
+
Validate and transform request request & response inputs / outputs.
|
|
244
346
|
|
|
245
|
-
|
|
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
|
-
|
|
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
|
|
395
|
+
const userIds = this.database.user.findAll({ page: query.page }).map(u => u.id);
|
|
285
396
|
|
|
286
|
-
return ResponseEntity.ok().body(
|
|
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:
|