@extk/expressive 0.7.1 → 0.9.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 +456 -349
- package/dist/index.d.mts +98 -62
- package/dist/index.d.ts +98 -62
- package/dist/index.js +61 -23
- package/dist/index.mjs +61 -23
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,349 +1,456 @@
|
|
|
1
|
-
<p align="center">
|
|
2
|
-
<picture>
|
|
3
|
-
<source media="(prefers-color-scheme: dark)" srcset="https://img.shields.io/badge/extk%E2%9A%A1-expressive-blue?style=for-the-badge&labelColor=1a1a2e&color=4361ee">
|
|
4
|
-
<img alt="extk/expressive logo" src="https://img.shields.io/badge/extk%E2%9A%A1-expressive-blue?style=for-the-badge&labelColor=f0f0f0&color=4361ee">
|
|
5
|
-
</picture>
|
|
6
|
-
</p>
|
|
7
|
-
|
|
8
|
-
<h3 align="center">Express 5 toolkit</h3>
|
|
9
|
-
<p align="center">Auto-generated OpenAPI docs, structured error handling, and logging — out of the box.</p>
|
|
10
|
-
|
|
11
|
-
<p align="center">
|
|
12
|
-
<img src="https://img.shields.io/npm/v/@extk/expressive" alt="npm version">
|
|
13
|
-
<img src="https://img.shields.io/node/v/@extk/expressive" alt="node version">
|
|
14
|
-
<img src="https://img.shields.io/npm/l/@extk/expressive" alt="license">
|
|
15
|
-
</p>
|
|
16
|
-
|
|
17
|
-
---
|
|
18
|
-
|
|
19
|
-
##
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
```ts
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
})
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://img.shields.io/badge/extk%E2%9A%A1-expressive-blue?style=for-the-badge&labelColor=1a1a2e&color=4361ee">
|
|
4
|
+
<img alt="extk/expressive logo" src="https://img.shields.io/badge/extk%E2%9A%A1-expressive-blue?style=for-the-badge&labelColor=f0f0f0&color=4361ee">
|
|
5
|
+
</picture>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<h3 align="center">Express 5 toolkit</h3>
|
|
9
|
+
<p align="center">Auto-generated OpenAPI docs, structured error handling, and logging — out of the box.</p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<img src="https://img.shields.io/npm/v/@extk/expressive" alt="npm version">
|
|
13
|
+
<img src="https://img.shields.io/node/v/@extk/expressive" alt="node version">
|
|
14
|
+
<img src="https://img.shields.io/npm/l/@extk/expressive" alt="license">
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Table of Contents
|
|
20
|
+
|
|
21
|
+
- [Table of Contents](#table-of-contents)
|
|
22
|
+
- [What is this?](#what-is-this)
|
|
23
|
+
- [Install](#install)
|
|
24
|
+
- [Quick Start](#quick-start)
|
|
25
|
+
- [Error Handling](#error-handling)
|
|
26
|
+
- [OpenAPI / Swagger](#openapi--swagger)
|
|
27
|
+
- [File uploads](#file-uploads)
|
|
28
|
+
- [Using Zod schemas for OpenAPI](#using-zod-schemas-for-openapi)
|
|
29
|
+
- [Middleware](#middleware)
|
|
30
|
+
- [`getApiErrorHandlerMiddleware(errorMapper?)`](#getapierrorhandlermiddlewareerrormapper)
|
|
31
|
+
- [`getApiNotFoundMiddleware()`](#getapinotfoundmiddleware)
|
|
32
|
+
- [`getGlobalNotFoundMiddleware(content?)`](#getglobalnotfoundmiddlewarecontent)
|
|
33
|
+
- [`getGlobalErrorHandlerMiddleware()`](#getglobalerrorhandlermiddleware)
|
|
34
|
+
- [`getBasicAuthMiddleware(basicAuthBase64, realm?)`](#getbasicauthmiddlewarebasicauthbase64-realm)
|
|
35
|
+
- [silently](#silently)
|
|
36
|
+
- [Logging](#logging)
|
|
37
|
+
- [Utilities](#utilities)
|
|
38
|
+
- [API Response Format](#api-response-format)
|
|
39
|
+
- [License](#license)
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## What is this?
|
|
44
|
+
|
|
45
|
+
`@extk/expressive` is an opinionated toolkit for Express 5 that wires up the things every API needs but nobody wants to set up from scratch:
|
|
46
|
+
|
|
47
|
+
- **Auto-generated OpenAPI 3.1 docs** from your route definitions
|
|
48
|
+
- **Structured error handling** with typed error classes and consistent JSON responses
|
|
49
|
+
- **Bring-your-own logger** — any object with `info/warn/error/debug` works
|
|
50
|
+
- **Security defaults** via Helmet, safe query parsing, and morgan request logging
|
|
51
|
+
- **Standardized responses** (`ApiResponse` / `ApiErrorResponse`) across your entire API
|
|
52
|
+
|
|
53
|
+
You write routes. Expressive handles the plumbing.
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npm install @extk/expressive express
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
> Requires Node.js >= 22 and Express 5.
|
|
62
|
+
|
|
63
|
+
## Quick Start
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import express from 'express';
|
|
67
|
+
import { bootstrap, ApiResponse, NotFoundError, SWG } from '@extk/expressive';
|
|
68
|
+
|
|
69
|
+
// 1. Bootstrap with a logger (bring your own)
|
|
70
|
+
const {
|
|
71
|
+
expressiveServer,
|
|
72
|
+
expressiveRouter,
|
|
73
|
+
notFoundMiddleware,
|
|
74
|
+
getErrorHandlerMiddleware,
|
|
75
|
+
silently,
|
|
76
|
+
} = bootstrap({
|
|
77
|
+
logger: console, // any object with info/warn/error/debug
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// 2. Define routes — they auto-register in the OpenAPI spec
|
|
81
|
+
const { router, addRoute } = expressiveRouter({
|
|
82
|
+
oapi: { tags: ['Users'] },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
addRoute(
|
|
86
|
+
{
|
|
87
|
+
method: 'get',
|
|
88
|
+
path: '/users/:id',
|
|
89
|
+
oapi: {
|
|
90
|
+
summary: 'Get user by ID',
|
|
91
|
+
responses: { 200: { description: 'User found' } },
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
async (req, res) => {
|
|
95
|
+
const user = await findUser(req.params.id);
|
|
96
|
+
if (!user) throw new NotFoundError('User not found');
|
|
97
|
+
res.json(new ApiResponse(user));
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
> [!IMPORTANT]
|
|
104
|
+
> Method call order on `ServerBuilder` matters — middleware is registered in the order you chain it.
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
// 3. Build the Express app
|
|
108
|
+
const app = expressiveServer()
|
|
109
|
+
.withHelmet()
|
|
110
|
+
.withQs()
|
|
111
|
+
.withMorgan()
|
|
112
|
+
.withRoutes(router)
|
|
113
|
+
.withSwagger(
|
|
114
|
+
b => b
|
|
115
|
+
.withInfo({ title: 'My API', version: '1.0.0' })
|
|
116
|
+
.withServers([{ url: 'http://localhost:3000' }]),
|
|
117
|
+
{ path: '/api-docs' },
|
|
118
|
+
)
|
|
119
|
+
.with((app) => {
|
|
120
|
+
app.use(getErrorHandlerMiddleware());
|
|
121
|
+
app.use(notFoundMiddleware);
|
|
122
|
+
})
|
|
123
|
+
.build();
|
|
124
|
+
|
|
125
|
+
app.listen(3000);
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Visit `http://localhost:3000/api-docs` to see the auto-generated Swagger UI.
|
|
129
|
+
|
|
130
|
+
## Error Handling
|
|
131
|
+
|
|
132
|
+
Throw typed errors anywhere in your handlers. The error middleware catches them and returns a consistent JSON response.
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
import { NotFoundError, BadRequestError, ForbiddenError } from '@extk/expressive';
|
|
136
|
+
|
|
137
|
+
// Throws -> { status: "error", message: "User not found", errorCode: "NOT_FOUND" }
|
|
138
|
+
throw new NotFoundError('User not found');
|
|
139
|
+
|
|
140
|
+
// Attach extra data (e.g. validation details)
|
|
141
|
+
throw new BadRequestError('Invalid input').setData({ field: 'email', issue: 'required' });
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Built-in error classes:
|
|
145
|
+
|
|
146
|
+
| Class | Status | Code |
|
|
147
|
+
| ------------------------ | ------ | ------------------------ |
|
|
148
|
+
| `BadRequestError` | 400 | `BAD_REQUEST` |
|
|
149
|
+
| `SchemaValidationError` | 400 | `SCHEMA_VALIDATION_ERROR`|
|
|
150
|
+
| `FileTooBigError` | 400 | `FILE_TOO_BIG` |
|
|
151
|
+
| `InvalidFileTypeError` | 400 | `INVALID_FILE_TYPE` |
|
|
152
|
+
| `InvalidCredentialsError`| 401 | `INVALID_CREDENTIALS` |
|
|
153
|
+
| `TokenExpiredError` | 401 | `TOKEN_EXPIRED` |
|
|
154
|
+
| `UserUnauthorizedError` | 401 | `USER_UNAUTHORIZED` |
|
|
155
|
+
| `ForbiddenError` | 403 | `FORBIDDEN` |
|
|
156
|
+
| `NotFoundError` | 404 | `NOT_FOUND` |
|
|
157
|
+
| `DuplicateError` | 409 | `DUPLICATE_ENTRY` |
|
|
158
|
+
| `TooManyRequestsError` | 429 | `TOO_MANY_REQUESTS` |
|
|
159
|
+
| `InternalError` | 500 | `INTERNAL_ERROR` |
|
|
160
|
+
|
|
161
|
+
You can also map external errors (e.g. Zod) via `getErrorHandlerMiddleware`:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
app.use(getErrorHandlerMiddleware((err) => {
|
|
165
|
+
if (err.name === 'ZodError') {
|
|
166
|
+
return new SchemaValidationError('Validation failed').setData(err.issues);
|
|
167
|
+
}
|
|
168
|
+
return null; // let the default handler deal with it
|
|
169
|
+
}));
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## OpenAPI / Swagger
|
|
173
|
+
|
|
174
|
+
Routes registered with `addRoute` are automatically added to the OpenAPI spec. Use the `SWG` helper to define parameters and schemas:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
addRoute(
|
|
178
|
+
{
|
|
179
|
+
method: 'get',
|
|
180
|
+
path: '/posts',
|
|
181
|
+
oapi: {
|
|
182
|
+
summary: 'List posts',
|
|
183
|
+
queryParameters: [
|
|
184
|
+
SWG.queryParam('page', { type: 'integer' }, false, 'Page number'),
|
|
185
|
+
SWG.queryParam('limit', { type: 'integer' }, false, 'Items per page'),
|
|
186
|
+
],
|
|
187
|
+
responses: {
|
|
188
|
+
200: { description: 'List of posts', ...SWG.jsonSchemaRef('PostList') },
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
listPostsHandler,
|
|
193
|
+
);
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### File uploads
|
|
197
|
+
|
|
198
|
+
Use `SWG.singleFileSchema` for a single file field, or `SWG.formDataSchema` for a custom multipart body:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
// single file — field name defaults to 'file', required defaults to true
|
|
202
|
+
addRoute({
|
|
203
|
+
method: 'post',
|
|
204
|
+
path: '/upload',
|
|
205
|
+
oapi: {
|
|
206
|
+
requestBody: SWG.singleFileSchema(),
|
|
207
|
+
// requestBody: SWG.singleFileSchema('avatar', true),
|
|
208
|
+
},
|
|
209
|
+
}, handler);
|
|
210
|
+
|
|
211
|
+
// custom multipart schema with multiple fields
|
|
212
|
+
addRoute({
|
|
213
|
+
method: 'post',
|
|
214
|
+
path: '/upload/rich',
|
|
215
|
+
oapi: {
|
|
216
|
+
requestBody: SWG.formDataSchema({
|
|
217
|
+
type: 'object',
|
|
218
|
+
properties: {
|
|
219
|
+
file: { type: 'string', format: 'binary' },
|
|
220
|
+
title: { type: 'string' },
|
|
221
|
+
},
|
|
222
|
+
required: ['file'],
|
|
223
|
+
}),
|
|
224
|
+
},
|
|
225
|
+
}, handler);
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Configure security schemes via the `configure` callback in `withSwagger`:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
.withSwagger(
|
|
232
|
+
b => b
|
|
233
|
+
.withSecuritySchemes({
|
|
234
|
+
BearerAuth: SWG.securitySchemes.BearerAuth(),
|
|
235
|
+
})
|
|
236
|
+
.withDefaultSecurity([SWG.security('BearerAuth')]),
|
|
237
|
+
{ path: '/api-docs' },
|
|
238
|
+
)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Using Zod schemas for OpenAPI
|
|
242
|
+
|
|
243
|
+
You can use Zod's global registry to define your schemas once and have them appear in both validation and OpenAPI docs automatically.
|
|
244
|
+
|
|
245
|
+
**1. Define schemas with `.meta({ id })` to register them globally:**
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
// schema/userSchema.ts
|
|
249
|
+
import z from 'zod';
|
|
250
|
+
|
|
251
|
+
export const createUserSchema = z.object({
|
|
252
|
+
email: z.string().email(),
|
|
253
|
+
password: z.string().min(8),
|
|
254
|
+
firstName: z.string(),
|
|
255
|
+
lastName: z.string(),
|
|
256
|
+
role: z.enum(['admin', 'user']),
|
|
257
|
+
}).meta({ id: 'createUser' });
|
|
258
|
+
|
|
259
|
+
export const patchUserSchema = createUserSchema.partial().meta({ id: 'patchUser' });
|
|
260
|
+
|
|
261
|
+
export const loginSchema = z.object({
|
|
262
|
+
username: z.string().email(),
|
|
263
|
+
password: z.string(),
|
|
264
|
+
}).meta({ id: 'login' });
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
**2. Pass all registered schemas to the swagger builder:**
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
import z from 'zod';
|
|
271
|
+
|
|
272
|
+
const app = expressiveServer()
|
|
273
|
+
.withHelmet()
|
|
274
|
+
.withQs()
|
|
275
|
+
.withMorgan()
|
|
276
|
+
.withSwagger(
|
|
277
|
+
b => b
|
|
278
|
+
.withInfo({ title: 'My API' })
|
|
279
|
+
.withServers([{ url: 'http://localhost:3000/api' }])
|
|
280
|
+
.withSchemas(z.toJSONSchema(z.globalRegistry).schemas) // all Zod schemas -> OpenAPI
|
|
281
|
+
.withSecuritySchemes({ auth: SWG.securitySchemes.BearerAuth() })
|
|
282
|
+
.withDefaultSecurity([SWG.security('auth')]),
|
|
283
|
+
{ path: '/api-docs' },
|
|
284
|
+
)
|
|
285
|
+
.build();
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
**3. Reference them in routes with `SWG.jsonSchemaRef`:**
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
addRoute({
|
|
292
|
+
method: 'post',
|
|
293
|
+
path: '/user',
|
|
294
|
+
oapi: {
|
|
295
|
+
summary: 'Create a user',
|
|
296
|
+
requestBody: SWG.jsonSchemaRef('createUser'),
|
|
297
|
+
},
|
|
298
|
+
}, async (req, res) => {
|
|
299
|
+
const body = createUserSchema.parse(req.body); // validate with the same schema
|
|
300
|
+
const result = await userController.createUser(body);
|
|
301
|
+
res.status(201).json(new ApiResponse(result));
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
addRoute({
|
|
305
|
+
method: 'patch',
|
|
306
|
+
path: '/user/:id',
|
|
307
|
+
oapi: {
|
|
308
|
+
summary: 'Update a user',
|
|
309
|
+
requestBody: SWG.jsonSchemaRef('patchUser'),
|
|
310
|
+
},
|
|
311
|
+
}, async (req, res) => {
|
|
312
|
+
const id = parseIdOrFail(req.params.id);
|
|
313
|
+
const body = patchUserSchema.parse(req.body);
|
|
314
|
+
const result = await userController.updateUser(id, body);
|
|
315
|
+
res.json(new ApiResponse(result));
|
|
316
|
+
});
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
This way your Zod schemas serve as the single source of truth for both runtime validation and API documentation.
|
|
320
|
+
|
|
321
|
+
## Middleware
|
|
322
|
+
|
|
323
|
+
All middleware factories are returned from `bootstrap()`.
|
|
324
|
+
|
|
325
|
+
### `getApiErrorHandlerMiddleware(errorMapper?)`
|
|
326
|
+
|
|
327
|
+
Express error handler for API routes. Catches `ApiError` subclasses, handles malformed JSON, and falls back to `InternalError` for unknown errors. Pass an optional `errorMapper` to map third-party errors (e.g. Zod, Multer) to typed `ApiError` instances.
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
app.use(getApiErrorHandlerMiddleware((err) => {
|
|
331
|
+
if (err.name === 'ZodError') return new SchemaValidationError('Validation failed').setData(err.issues);
|
|
332
|
+
return null;
|
|
333
|
+
}));
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### `getApiNotFoundMiddleware()`
|
|
337
|
+
|
|
338
|
+
Returns a JSON `404` response for unmatched API routes.
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
app.use(getApiNotFoundMiddleware());
|
|
342
|
+
// { status: 'error', message: 'GET /unknown not found', errorCode: 'NOT_FOUND' }
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### `getGlobalNotFoundMiddleware(content?)`
|
|
346
|
+
|
|
347
|
+
Returns a plain-text `404`. Useful as the last catch-all for non-API routes. Defaults to `¯\_(ツ)_/¯`.
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
app.use(getGlobalNotFoundMiddleware());
|
|
351
|
+
app.use(getGlobalNotFoundMiddleware('Not found'));
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
### `getGlobalErrorHandlerMiddleware()`
|
|
355
|
+
|
|
356
|
+
Minimal error handler that logs and responds with a plain-text `500`. Use this outside of API route groups where JSON responses aren't expected.
|
|
357
|
+
|
|
358
|
+
### `getBasicAuthMiddleware(basicAuthBase64, realm?)`
|
|
359
|
+
|
|
360
|
+
Protects a route or the Swagger UI with HTTP Basic auth. Accepts a pre-encoded base64 `user:password` string.
|
|
361
|
+
|
|
362
|
+
```ts
|
|
363
|
+
expressiveServer()
|
|
364
|
+
.withSwagger(
|
|
365
|
+
b => b,
|
|
366
|
+
{ path: '/api-docs' },
|
|
367
|
+
getBasicAuthMiddleware(process.env.SWAGGER_AUTH ?? '', 'API Docs'),
|
|
368
|
+
)
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
## silently
|
|
372
|
+
|
|
373
|
+
`silently` runs a function — sync or async — and suppresses any errors it throws. Errors are forwarded to `alertHandler` (if configured) or logged via the container logger.
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
// fire-and-forget without crashing the process
|
|
377
|
+
silently(() => sendAnalyticsEvent(req));
|
|
378
|
+
silently(async () => await notifySlack('Server started'));
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
## Logging
|
|
382
|
+
|
|
383
|
+
Expressive does not bundle a logger. Instead, `bootstrap` accepts any object that satisfies the `Logger` interface:
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
export type Logger = {
|
|
387
|
+
info(message: string, ...args: any[]): void;
|
|
388
|
+
error(message: string | Error | unknown, ...args: any[]): void;
|
|
389
|
+
warn(message: string, ...args: any[]): void;
|
|
390
|
+
debug(message: string, ...args: any[]): void;
|
|
391
|
+
};
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
This means you can pass `console` directly, or plug in any logging library (Winston, Pino, etc.):
|
|
395
|
+
|
|
396
|
+
```ts
|
|
397
|
+
bootstrap({ logger: console });
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
The `@extk/logger-cloudwatch` package from the same org is a drop-in fit:
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
import { getCloudwatchLogger, getConsoleLogger } from '@extk/logger-cloudwatch';
|
|
404
|
+
|
|
405
|
+
// development
|
|
406
|
+
bootstrap({ logger: getConsoleLogger() });
|
|
407
|
+
|
|
408
|
+
// production — streams structured JSON logs to AWS CloudWatch
|
|
409
|
+
bootstrap({
|
|
410
|
+
logger: getCloudwatchLogger({
|
|
411
|
+
aws: {
|
|
412
|
+
region: 'us-east-1',
|
|
413
|
+
logGroup: '/my-app/production',
|
|
414
|
+
credentials: {
|
|
415
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
416
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
}),
|
|
420
|
+
});
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
## Utilities
|
|
424
|
+
|
|
425
|
+
```ts
|
|
426
|
+
import {
|
|
427
|
+
slugify,
|
|
428
|
+
parseDefaultPagination,
|
|
429
|
+
parseIdOrFail,
|
|
430
|
+
getEnvVar,
|
|
431
|
+
isDev,
|
|
432
|
+
isProd,
|
|
433
|
+
} from '@extk/expressive';
|
|
434
|
+
|
|
435
|
+
slugify('Hello World!'); // 'hello-world!'
|
|
436
|
+
parseDefaultPagination({ page: '2', limit: '25' }); // { offset: 25, limit: 25 }
|
|
437
|
+
parseIdOrFail('42'); // 42 (throws on invalid)
|
|
438
|
+
getEnvVar('DATABASE_URL'); // string (throws if missing)
|
|
439
|
+
isDev(); // true when ENV !== 'prod'
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
## API Response Format
|
|
443
|
+
|
|
444
|
+
All responses follow a consistent shape:
|
|
445
|
+
|
|
446
|
+
```jsonc
|
|
447
|
+
// Success
|
|
448
|
+
{ "status": "ok", "result": { /* ... */ } }
|
|
449
|
+
|
|
450
|
+
// Error
|
|
451
|
+
{ "status": "error", "message": "Not found", "errorCode": "NOT_FOUND", "errors": null }
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
## License
|
|
455
|
+
|
|
456
|
+
ISC
|