@extk/expressive 0.4.1 → 0.4.3
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 +294 -0
- package/dist/index.d.mts +3 -2
- package/dist/index.d.ts +3 -2
- package/package.json +20 -3
package/README.md
CHANGED
|
@@ -0,0 +1,294 @@
|
|
|
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
|
+
## What is this?
|
|
20
|
+
|
|
21
|
+
`@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:
|
|
22
|
+
|
|
23
|
+
- **Auto-generated OpenAPI 3.1 docs** from your route definitions
|
|
24
|
+
- **Structured error handling** with typed error classes and consistent JSON responses
|
|
25
|
+
- **Winston logging** with daily file rotation and dev/prod modes
|
|
26
|
+
- **Security defaults** via Helmet, safe query parsing, and morgan request logging
|
|
27
|
+
- **Standardized responses** (`ApiResponse` / `ApiErrorResponse`) across your entire API
|
|
28
|
+
|
|
29
|
+
You write routes. Expressive handles the plumbing.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install @extk/expressive express
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
> Requires Node.js >= 22 and Express 5.
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import express from 'express';
|
|
43
|
+
import { bootstrap, getDefaultFileLogger, ApiResponse, NotFoundError, SWG } from '@extk/expressive';
|
|
44
|
+
|
|
45
|
+
// 1. Bootstrap with a logger
|
|
46
|
+
const {
|
|
47
|
+
expressiveServer,
|
|
48
|
+
expressiveRouter,
|
|
49
|
+
swaggerBuilder,
|
|
50
|
+
notFoundMiddleware,
|
|
51
|
+
getErrorHandlerMiddleware,
|
|
52
|
+
} = bootstrap({
|
|
53
|
+
logger: getDefaultFileLogger('my-api'),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// 2. Configure swagger metadata
|
|
57
|
+
const swaggerDoc = swaggerBuilder()
|
|
58
|
+
.withInfo({ title: 'My API', version: '1.0.0' })
|
|
59
|
+
.withServers([{ url: 'http://localhost:3000' }])
|
|
60
|
+
.get();
|
|
61
|
+
|
|
62
|
+
// 3. Build the Express app with sensible defaults (helmet, morgan, swagger UI)
|
|
63
|
+
const app = expressiveServer()
|
|
64
|
+
.withDefaults({ path: '/api-docs', doc: swaggerDoc });
|
|
65
|
+
|
|
66
|
+
// 4. Define routes — they auto-register in the OpenAPI spec
|
|
67
|
+
const { router, addRoute } = expressiveRouter({
|
|
68
|
+
oapi: { tags: ['Users'] },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
addRoute(
|
|
72
|
+
{
|
|
73
|
+
method: 'get',
|
|
74
|
+
path: '/users/:id',
|
|
75
|
+
oapi: {
|
|
76
|
+
summary: 'Get user by ID',
|
|
77
|
+
responses: { 200: { description: 'User found' } },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
async (req, res) => {
|
|
81
|
+
const user = await findUser(req.params.id);
|
|
82
|
+
if (!user) throw new NotFoundError('User not found');
|
|
83
|
+
res.json(new ApiResponse(user));
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// 5. Mount the router and error handling
|
|
88
|
+
app.use(router);
|
|
89
|
+
app.use(getErrorHandlerMiddleware());
|
|
90
|
+
app.use(notFoundMiddleware);
|
|
91
|
+
|
|
92
|
+
app.listen(3000);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Visit `http://localhost:3000/api-docs` to see the auto-generated Swagger UI.
|
|
96
|
+
|
|
97
|
+
## Error Handling
|
|
98
|
+
|
|
99
|
+
Throw typed errors anywhere in your handlers. The error middleware catches them and returns a consistent JSON response.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { NotFoundError, BadRequestError, ForbiddenError } from '@extk/expressive';
|
|
103
|
+
|
|
104
|
+
// Throws -> { status: "error", message: "User not found", errorCode: "NOT_FOUND" }
|
|
105
|
+
throw new NotFoundError('User not found');
|
|
106
|
+
|
|
107
|
+
// Attach extra data (e.g. validation details)
|
|
108
|
+
throw new BadRequestError('Invalid input').setData({ field: 'email', issue: 'required' });
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Built-in error classes:
|
|
112
|
+
|
|
113
|
+
| Class | Status | Code |
|
|
114
|
+
| ------------------------ | ------ | ------------------------ |
|
|
115
|
+
| `BadRequestError` | 400 | `BAD_REQUEST` |
|
|
116
|
+
| `SchemaValidationError` | 400 | `SCHEMA_VALIDATION_ERROR`|
|
|
117
|
+
| `FileTooBigError` | 400 | `FILE_TOO_BIG` |
|
|
118
|
+
| `InvalidFileTypeError` | 400 | `INVALID_FILE_TYPE` |
|
|
119
|
+
| `InvalidCredentialsError`| 401 | `INVALID_CREDENTIALS` |
|
|
120
|
+
| `TokenExpiredError` | 401 | `TOKEN_EXPIRED` |
|
|
121
|
+
| `UserUnauthorizedError` | 401 | `USER_UNAUTHORIZED` |
|
|
122
|
+
| `ForbiddenError` | 403 | `FORBIDDEN` |
|
|
123
|
+
| `NotFoundError` | 404 | `NOT_FOUND` |
|
|
124
|
+
| `DuplicateError` | 409 | `DUPLICATE_ENTRY` |
|
|
125
|
+
| `TooManyRequestsError` | 429 | `TOO_MANY_REQUESTS` |
|
|
126
|
+
| `InternalError` | 500 | `INTERNAL_ERROR` |
|
|
127
|
+
|
|
128
|
+
You can also map external errors (e.g. Zod) via `getErrorHandlerMiddleware`:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
app.use(getErrorHandlerMiddleware((err) => {
|
|
132
|
+
if (err.name === 'ZodError') {
|
|
133
|
+
return new SchemaValidationError('Validation failed').setData(err.issues);
|
|
134
|
+
}
|
|
135
|
+
return null; // let the default handler deal with it
|
|
136
|
+
}));
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## OpenAPI / Swagger
|
|
140
|
+
|
|
141
|
+
Routes registered with `addRoute` are automatically added to the OpenAPI spec. Use the `SWG` helper to define parameters and schemas:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
addRoute(
|
|
145
|
+
{
|
|
146
|
+
method: 'get',
|
|
147
|
+
path: '/posts',
|
|
148
|
+
oapi: {
|
|
149
|
+
summary: 'List posts',
|
|
150
|
+
queryParameters: [
|
|
151
|
+
SWG.queryParam('page', { type: 'integer' }, false, 'Page number'),
|
|
152
|
+
SWG.queryParam('limit', { type: 'integer' }, false, 'Items per page'),
|
|
153
|
+
],
|
|
154
|
+
responses: {
|
|
155
|
+
200: { description: 'List of posts', ...SWG.jsonSchemaRef('PostList') },
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
listPostsHandler,
|
|
160
|
+
);
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Configure security schemes via the swagger builder:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
swaggerBuilder()
|
|
167
|
+
.withSecuritySchemes({
|
|
168
|
+
BearerAuth: SWG.securitySchemes.BearerAuth(),
|
|
169
|
+
})
|
|
170
|
+
.withDefaultSecurity([SWG.security('BearerAuth')]);
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Using Zod schemas for OpenAPI
|
|
174
|
+
|
|
175
|
+
You can use Zod's global registry to define your schemas once and have them appear in both validation and OpenAPI docs automatically.
|
|
176
|
+
|
|
177
|
+
**1. Define schemas with `.meta({ id })` to register them globally:**
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
// schema/userSchema.ts
|
|
181
|
+
import z from 'zod';
|
|
182
|
+
|
|
183
|
+
export const createUserSchema = z.object({
|
|
184
|
+
email: z.string().email(),
|
|
185
|
+
password: z.string().min(8),
|
|
186
|
+
firstName: z.string(),
|
|
187
|
+
lastName: z.string(),
|
|
188
|
+
role: z.enum(['admin', 'user']),
|
|
189
|
+
}).meta({ id: 'createUser' });
|
|
190
|
+
|
|
191
|
+
export const patchUserSchema = createUserSchema.partial().meta({ id: 'patchUser' });
|
|
192
|
+
|
|
193
|
+
export const loginSchema = z.object({
|
|
194
|
+
username: z.string().email(),
|
|
195
|
+
password: z.string(),
|
|
196
|
+
}).meta({ id: 'login' });
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**2. Pass all registered schemas to the swagger builder:**
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
import z from 'zod';
|
|
203
|
+
|
|
204
|
+
const app = expressiveServer()
|
|
205
|
+
.withDefaults({
|
|
206
|
+
doc: swaggerBuilder()
|
|
207
|
+
.withInfo({ title: 'My API' })
|
|
208
|
+
.withServers([{ url: 'http://localhost:3000/api' }])
|
|
209
|
+
.withSchemas(z.toJSONSchema(z.globalRegistry).schemas) // all Zod schemas -> OpenAPI
|
|
210
|
+
.withSecuritySchemes({ auth: SWG.securitySchemes.BearerAuth() })
|
|
211
|
+
.withDefaultSecurity([SWG.security('auth')])
|
|
212
|
+
.get(),
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**3. Reference them in routes with `SWG.jsonSchemaRef`:**
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
addRoute({
|
|
220
|
+
method: 'post',
|
|
221
|
+
path: '/user',
|
|
222
|
+
oapi: {
|
|
223
|
+
summary: 'Create a user',
|
|
224
|
+
requestBody: SWG.jsonSchemaRef('createUser'),
|
|
225
|
+
},
|
|
226
|
+
}, async (req, res) => {
|
|
227
|
+
const body = createUserSchema.parse(req.body); // validate with the same schema
|
|
228
|
+
const result = await userController.createUser(body);
|
|
229
|
+
res.status(201).json(new ApiResponse(result));
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
addRoute({
|
|
233
|
+
method: 'patch',
|
|
234
|
+
path: '/user/:id',
|
|
235
|
+
oapi: {
|
|
236
|
+
summary: 'Update a user',
|
|
237
|
+
requestBody: SWG.jsonSchemaRef('patchUser'),
|
|
238
|
+
},
|
|
239
|
+
}, async (req, res) => {
|
|
240
|
+
const id = parseIdOrFail(req.params.id);
|
|
241
|
+
const body = patchUserSchema.parse(req.body);
|
|
242
|
+
const result = await userController.updateUser(id, body);
|
|
243
|
+
res.json(new ApiResponse(result));
|
|
244
|
+
});
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
This way your Zod schemas serve as the single source of truth for both runtime validation and API documentation.
|
|
248
|
+
|
|
249
|
+
## Logging
|
|
250
|
+
|
|
251
|
+
Winston-based logging with daily rotating files in production and console output in development.
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
import { getDefaultFileLogger, getDefaultConsoleLogger } from '@extk/expressive';
|
|
255
|
+
|
|
256
|
+
const logger = getDefaultFileLogger('my-service'); // logs to ./logs/my-service-YYYY-MM-DD.log
|
|
257
|
+
logger.info('Server started on port %d', 3000);
|
|
258
|
+
logger.error('Something went wrong: %s', err.message);
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## Utilities
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import {
|
|
265
|
+
slugify,
|
|
266
|
+
parseDefaultPagination,
|
|
267
|
+
parseIdOrFail,
|
|
268
|
+
getEnvVar,
|
|
269
|
+
isDev,
|
|
270
|
+
isProd,
|
|
271
|
+
} from '@extk/expressive';
|
|
272
|
+
|
|
273
|
+
slugify('Hello World!'); // 'hello-world!'
|
|
274
|
+
parseDefaultPagination({ page: '2', limit: '25' }); // { offset: 25, limit: 25 }
|
|
275
|
+
parseIdOrFail('42'); // 42 (throws on invalid)
|
|
276
|
+
getEnvVar('DATABASE_URL'); // string (throws if missing)
|
|
277
|
+
isDev(); // true when ENV !== 'prod'
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## API Response Format
|
|
281
|
+
|
|
282
|
+
All responses follow a consistent shape:
|
|
283
|
+
|
|
284
|
+
```jsonc
|
|
285
|
+
// Success
|
|
286
|
+
{ "status": "ok", "result": { /* ... */ } }
|
|
287
|
+
|
|
288
|
+
// Error
|
|
289
|
+
{ "status": "error", "message": "Not found", "errorCode": "NOT_FOUND", "errors": null }
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
## License
|
|
293
|
+
|
|
294
|
+
ISC
|
package/dist/index.d.mts
CHANGED
|
@@ -26,6 +26,7 @@ type ReqSnapshot = {
|
|
|
26
26
|
|
|
27
27
|
type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'options' | 'trace';
|
|
28
28
|
type OtherString = string & {};
|
|
29
|
+
type OtherUnknown = unknown & {};
|
|
29
30
|
type ContentType = 'application/json' | 'application/xml' | 'text/plain' | 'text/html' | OtherString;
|
|
30
31
|
type AlertHandler = (err: Error & Record<string, any>, reqSnapshot?: ReqSnapshot) => void | Promise<void>;
|
|
31
32
|
type Logger = Logger$1;
|
|
@@ -88,7 +89,7 @@ type Schema = BaseSchema | {
|
|
|
88
89
|
anyOf: Schema[];
|
|
89
90
|
} | {
|
|
90
91
|
oneOf: Schema[];
|
|
91
|
-
} |
|
|
92
|
+
} | OtherUnknown;
|
|
92
93
|
type Content = {
|
|
93
94
|
description?: string;
|
|
94
95
|
content?: Partial<Record<ContentType, {
|
|
@@ -320,4 +321,4 @@ declare function bootstrap(container: Container): {
|
|
|
320
321
|
};
|
|
321
322
|
};
|
|
322
323
|
|
|
323
|
-
export { type AlertHandler, ApiError, ApiErrorResponse, ApiResponse, type AuthMethod, BadRequestError, type Container, type Content, type ContentType, DuplicateError, type Env, type ExpressHandler, type ExpressLocalsObj, type ExpressRoute, FileTooBigError, ForbiddenError, type HttpMethod, InternalError, InvalidCredentialsError, InvalidFileTypeError, type Logger, NotFoundError, type OtherString, type Pagination, type PaginationQuery, type Param, type PathItem, type ReqSnapshot, SWG, type Schema, SchemaValidationError, type SecurityScheme, type Servers, type SwaggerConfig, TokenExpiredError, TooManyRequestsError, UserUnauthorizedError, bootstrap, createLogger, createReqSnapshot, getDefaultConsoleLogger, getDefaultFileLogger, getEnvVar, getTmpDir, getTmpPath, isDev, isProd, parseDefaultPagination, parseIdOrFail, parsePositiveInteger, slugify };
|
|
324
|
+
export { type AlertHandler, ApiError, ApiErrorResponse, ApiResponse, type AuthMethod, BadRequestError, type Container, type Content, type ContentType, DuplicateError, type Env, type ExpressHandler, type ExpressLocalsObj, type ExpressRoute, FileTooBigError, ForbiddenError, type HttpMethod, InternalError, InvalidCredentialsError, InvalidFileTypeError, type Logger, NotFoundError, type OtherString, type OtherUnknown, type Pagination, type PaginationQuery, type Param, type PathItem, type ReqSnapshot, SWG, type Schema, SchemaValidationError, type SecurityScheme, type Servers, type SwaggerConfig, TokenExpiredError, TooManyRequestsError, UserUnauthorizedError, bootstrap, createLogger, createReqSnapshot, getDefaultConsoleLogger, getDefaultFileLogger, getEnvVar, getTmpDir, getTmpPath, isDev, isProd, parseDefaultPagination, parseIdOrFail, parsePositiveInteger, slugify };
|
package/dist/index.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ type ReqSnapshot = {
|
|
|
26
26
|
|
|
27
27
|
type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'options' | 'trace';
|
|
28
28
|
type OtherString = string & {};
|
|
29
|
+
type OtherUnknown = unknown & {};
|
|
29
30
|
type ContentType = 'application/json' | 'application/xml' | 'text/plain' | 'text/html' | OtherString;
|
|
30
31
|
type AlertHandler = (err: Error & Record<string, any>, reqSnapshot?: ReqSnapshot) => void | Promise<void>;
|
|
31
32
|
type Logger = Logger$1;
|
|
@@ -88,7 +89,7 @@ type Schema = BaseSchema | {
|
|
|
88
89
|
anyOf: Schema[];
|
|
89
90
|
} | {
|
|
90
91
|
oneOf: Schema[];
|
|
91
|
-
} |
|
|
92
|
+
} | OtherUnknown;
|
|
92
93
|
type Content = {
|
|
93
94
|
description?: string;
|
|
94
95
|
content?: Partial<Record<ContentType, {
|
|
@@ -320,4 +321,4 @@ declare function bootstrap(container: Container): {
|
|
|
320
321
|
};
|
|
321
322
|
};
|
|
322
323
|
|
|
323
|
-
export { type AlertHandler, ApiError, ApiErrorResponse, ApiResponse, type AuthMethod, BadRequestError, type Container, type Content, type ContentType, DuplicateError, type Env, type ExpressHandler, type ExpressLocalsObj, type ExpressRoute, FileTooBigError, ForbiddenError, type HttpMethod, InternalError, InvalidCredentialsError, InvalidFileTypeError, type Logger, NotFoundError, type OtherString, type Pagination, type PaginationQuery, type Param, type PathItem, type ReqSnapshot, SWG, type Schema, SchemaValidationError, type SecurityScheme, type Servers, type SwaggerConfig, TokenExpiredError, TooManyRequestsError, UserUnauthorizedError, bootstrap, createLogger, createReqSnapshot, getDefaultConsoleLogger, getDefaultFileLogger, getEnvVar, getTmpDir, getTmpPath, isDev, isProd, parseDefaultPagination, parseIdOrFail, parsePositiveInteger, slugify };
|
|
324
|
+
export { type AlertHandler, ApiError, ApiErrorResponse, ApiResponse, type AuthMethod, BadRequestError, type Container, type Content, type ContentType, DuplicateError, type Env, type ExpressHandler, type ExpressLocalsObj, type ExpressRoute, FileTooBigError, ForbiddenError, type HttpMethod, InternalError, InvalidCredentialsError, InvalidFileTypeError, type Logger, NotFoundError, type OtherString, type OtherUnknown, type Pagination, type PaginationQuery, type Param, type PathItem, type ReqSnapshot, SWG, type Schema, SchemaValidationError, type SecurityScheme, type Servers, type SwaggerConfig, TokenExpiredError, TooManyRequestsError, UserUnauthorizedError, bootstrap, createLogger, createReqSnapshot, getDefaultConsoleLogger, getDefaultFileLogger, getEnvVar, getTmpDir, getTmpPath, isDev, isProd, parseDefaultPagination, parseIdOrFail, parsePositiveInteger, slugify };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@extk/expressive",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"type": "commonjs",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"engines": {
|
|
19
|
-
"node": ">=22.0.0
|
|
19
|
+
"node": ">=22.0.0"
|
|
20
20
|
},
|
|
21
21
|
"scripts": {
|
|
22
22
|
"build": "tsup",
|
|
@@ -51,5 +51,22 @@
|
|
|
51
51
|
},
|
|
52
52
|
"author": "",
|
|
53
53
|
"license": "ISC",
|
|
54
|
-
"description": ""
|
|
54
|
+
"description": "Production-ready Express 5 toolkit with auto-generated OpenAPI docs, structured error handling, and logging",
|
|
55
|
+
"keywords": [
|
|
56
|
+
"express",
|
|
57
|
+
"express5",
|
|
58
|
+
"openapi",
|
|
59
|
+
"swagger",
|
|
60
|
+
"api",
|
|
61
|
+
"rest",
|
|
62
|
+
"middleware",
|
|
63
|
+
"error-handling",
|
|
64
|
+
"logging",
|
|
65
|
+
"winston",
|
|
66
|
+
"helmet",
|
|
67
|
+
"typescript",
|
|
68
|
+
"zod",
|
|
69
|
+
"api-framework",
|
|
70
|
+
"express-toolkit"
|
|
71
|
+
]
|
|
55
72
|
}
|