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