Package Exports
- @extk/expressive
Readme
Express 5 toolkit
Auto-generated OpenAPI docs, structured error handling, and logging — out of the box.
What is this?
@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:
- Auto-generated OpenAPI 3.1 docs from your route definitions
- Structured error handling with typed error classes and consistent JSON responses
- Winston logging with daily file rotation and dev/prod modes
- Security defaults via Helmet, safe query parsing, and morgan request logging
- Standardized responses (
ApiResponse/ApiErrorResponse) across your entire API
You write routes. Expressive handles the plumbing.
Install
npm install @extk/expressive expressRequires Node.js >= 22 and Express 5.
Quick Start
import express from 'express';
import { bootstrap, getDefaultFileLogger, ApiResponse, NotFoundError, SWG } from '@extk/expressive';
// 1. Bootstrap with a logger
const {
expressiveServer,
expressiveRouter,
swaggerBuilder,
notFoundMiddleware,
getErrorHandlerMiddleware,
} = bootstrap({
logger: getDefaultFileLogger('my-api'),
});
// 2. Configure swagger metadata
const swaggerDoc = swaggerBuilder()
.withInfo({ title: 'My API', version: '1.0.0' })
.withServers([{ url: 'http://localhost:3000' }])
.get();
// 3. Build the Express app with sensible defaults (helmet, morgan, swagger UI)
const app = expressiveServer()
.withDefaults({ path: '/api-docs', doc: swaggerDoc });
// 4. Define routes — they auto-register in the OpenAPI spec
const { router, addRoute } = expressiveRouter({
oapi: { tags: ['Users'] },
});
addRoute(
{
method: 'get',
path: '/users/:id',
oapi: {
summary: 'Get user by ID',
responses: { 200: { description: 'User found' } },
},
},
async (req, res) => {
const user = await findUser(req.params.id);
if (!user) throw new NotFoundError('User not found');
res.json(new ApiResponse(user));
},
);
// 5. Mount the router and error handling
app.use(router);
app.use(getErrorHandlerMiddleware());
app.use(notFoundMiddleware);
app.listen(3000);Visit http://localhost:3000/api-docs to see the auto-generated Swagger UI.
Error Handling
Throw typed errors anywhere in your handlers. The error middleware catches them and returns a consistent JSON response.
import { NotFoundError, BadRequestError, ForbiddenError } from '@extk/expressive';
// Throws -> { status: "error", message: "User not found", errorCode: "NOT_FOUND" }
throw new NotFoundError('User not found');
// Attach extra data (e.g. validation details)
throw new BadRequestError('Invalid input').setData({ field: 'email', issue: 'required' });Built-in error classes:
| Class | Status | Code |
|---|---|---|
BadRequestError |
400 | BAD_REQUEST |
SchemaValidationError |
400 | SCHEMA_VALIDATION_ERROR |
FileTooBigError |
400 | FILE_TOO_BIG |
InvalidFileTypeError |
400 | INVALID_FILE_TYPE |
InvalidCredentialsError |
401 | INVALID_CREDENTIALS |
TokenExpiredError |
401 | TOKEN_EXPIRED |
UserUnauthorizedError |
401 | USER_UNAUTHORIZED |
ForbiddenError |
403 | FORBIDDEN |
NotFoundError |
404 | NOT_FOUND |
DuplicateError |
409 | DUPLICATE_ENTRY |
TooManyRequestsError |
429 | TOO_MANY_REQUESTS |
InternalError |
500 | INTERNAL_ERROR |
You can also map external errors (e.g. Zod) via getErrorHandlerMiddleware:
app.use(getErrorHandlerMiddleware((err) => {
if (err.name === 'ZodError') {
return new SchemaValidationError('Validation failed').setData(err.issues);
}
return null; // let the default handler deal with it
}));OpenAPI / Swagger
Routes registered with addRoute are automatically added to the OpenAPI spec. Use the SWG helper to define parameters and schemas:
addRoute(
{
method: 'get',
path: '/posts',
oapi: {
summary: 'List posts',
queryParameters: [
SWG.queryParam('page', { type: 'integer' }, false, 'Page number'),
SWG.queryParam('limit', { type: 'integer' }, false, 'Items per page'),
],
responses: {
200: { description: 'List of posts', ...SWG.jsonSchemaRef('PostList') },
},
},
},
listPostsHandler,
);Configure security schemes via the swagger builder:
swaggerBuilder()
.withSecuritySchemes({
BearerAuth: SWG.securitySchemes.BearerAuth(),
})
.withDefaultSecurity([SWG.security('BearerAuth')]);Using Zod schemas for OpenAPI
You can use Zod's global registry to define your schemas once and have them appear in both validation and OpenAPI docs automatically.
1. Define schemas with .meta({ id }) to register them globally:
// schema/userSchema.ts
import z from 'zod';
export const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
firstName: z.string(),
lastName: z.string(),
role: z.enum(['admin', 'user']),
}).meta({ id: 'createUser' });
export const patchUserSchema = createUserSchema.partial().meta({ id: 'patchUser' });
export const loginSchema = z.object({
username: z.string().email(),
password: z.string(),
}).meta({ id: 'login' });2. Pass all registered schemas to the swagger builder:
import z from 'zod';
const app = expressiveServer()
.withDefaults({
doc: swaggerBuilder()
.withInfo({ title: 'My API' })
.withServers([{ url: 'http://localhost:3000/api' }])
.withSchemas(z.toJSONSchema(z.globalRegistry).schemas) // all Zod schemas -> OpenAPI
.withSecuritySchemes({ auth: SWG.securitySchemes.BearerAuth() })
.withDefaultSecurity([SWG.security('auth')])
.get(),
});3. Reference them in routes with SWG.jsonSchemaRef:
addRoute({
method: 'post',
path: '/user',
oapi: {
summary: 'Create a user',
requestBody: SWG.jsonSchemaRef('createUser'),
},
}, async (req, res) => {
const body = createUserSchema.parse(req.body); // validate with the same schema
const result = await userController.createUser(body);
res.status(201).json(new ApiResponse(result));
});
addRoute({
method: 'patch',
path: '/user/:id',
oapi: {
summary: 'Update a user',
requestBody: SWG.jsonSchemaRef('patchUser'),
},
}, async (req, res) => {
const id = parseIdOrFail(req.params.id);
const body = patchUserSchema.parse(req.body);
const result = await userController.updateUser(id, body);
res.json(new ApiResponse(result));
});This way your Zod schemas serve as the single source of truth for both runtime validation and API documentation.
Logging
Winston-based logging with daily rotating files in production and console output in development.
import { getDefaultFileLogger, getDefaultConsoleLogger } from '@extk/expressive';
const logger = getDefaultFileLogger('my-service'); // logs to ./logs/my-service-YYYY-MM-DD.log
logger.info('Server started on port %d', 3000);
logger.error('Something went wrong: %s', err.message);Utilities
import {
slugify,
parseDefaultPagination,
parseIdOrFail,
getEnvVar,
isDev,
isProd,
} from '@extk/expressive';
slugify('Hello World!'); // 'hello-world!'
parseDefaultPagination({ page: '2', limit: '25' }); // { offset: 25, limit: 25 }
parseIdOrFail('42'); // 42 (throws on invalid)
getEnvVar('DATABASE_URL'); // string (throws if missing)
isDev(); // true when ENV !== 'prod'API Response Format
All responses follow a consistent shape:
// Success
{ "status": "ok", "result": { /* ... */ } }
// Error
{ "status": "error", "message": "Not found", "errorCode": "NOT_FOUND", "errors": null }License
ISC