@extk/expressive
v0.8.0
Published
Production-ready Express 5 toolkit with auto-generated OpenAPI docs, structured error handling, and logging
Maintainers
Readme
Table of Contents
- What is this?
- Install
- Quick Start
- Error Handling
- OpenAPI / Swagger
- Middleware
- silently
- Logging
- Utilities
- API Response Format
- License
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
- Bring-your-own logger — any object with
info/warn/error/debugworks - 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, ApiResponse, NotFoundError, SWG } from '@extk/expressive';
// 1. Bootstrap with a logger (bring your own)
const {
expressiveServer,
expressiveRouter,
swaggerBuilder,
notFoundMiddleware,
getErrorHandlerMiddleware,
silently,
} = bootstrap({
logger: console, // any object with info/warn/error/debug
});
// 2. Configure swagger metadata
const swaggerDoc = swaggerBuilder()
.withInfo({ title: 'My API', version: '1.0.0' })
.withServers([{ url: 'http://localhost:3000' }])
.build();
// 3. 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));
},
);
[!IMPORTANT] Method call order on
ServerBuildermatters — middleware is registered in the order you chain it.
// 4. Build the Express app
const app = expressiveServer()
.withHelmet()
.withQs()
.withMorgan()
.withRoutes(router)
.withSwagger({ path: '/api-docs', config: swaggerDoc })
.with((app) => {
app.use(getErrorHandlerMiddleware());
app.use(notFoundMiddleware);
})
.build();
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,
);File uploads
Use SWG.singleFileSchema for a single file field, or SWG.formDataSchema for a custom multipart body:
// single file — field name defaults to 'file', required defaults to true
addRoute({
method: 'post',
path: '/upload',
oapi: {
requestBody: SWG.singleFileSchema(),
// requestBody: SWG.singleFileSchema('avatar', true),
},
}, handler);
// custom multipart schema with multiple fields
addRoute({
method: 'post',
path: '/upload/rich',
oapi: {
requestBody: SWG.formDataSchema({
type: 'object',
properties: {
file: { type: 'string', format: 'binary' },
title: { type: 'string' },
},
required: ['file'],
}),
},
}, handler);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()
.withHelmet()
.withQs()
.withMorgan()
.withSwagger({
config: 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(),
})
.build();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.
Middleware
All middleware factories are returned from bootstrap().
getApiErrorHandlerMiddleware(errorMapper?)
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.
app.use(getApiErrorHandlerMiddleware((err) => {
if (err.name === 'ZodError') return new SchemaValidationError('Validation failed').setData(err.issues);
return null;
}));getApiNotFoundMiddleware()
Returns a JSON 404 response for unmatched API routes.
app.use(getApiNotFoundMiddleware());
// { status: 'error', message: 'GET /unknown not found', errorCode: 'NOT_FOUND' }getGlobalNotFoundMiddleware(content?)
Returns a plain-text 404. Useful as the last catch-all for non-API routes. Defaults to ¯\_(ツ)_/¯.
app.use(getGlobalNotFoundMiddleware());
app.use(getGlobalNotFoundMiddleware('Not found'));getGlobalErrorHandlerMiddleware()
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.
getBasicAuthMiddleware(basicAuthBase64, realm?)
Protects a route or the Swagger UI with HTTP Basic auth. Accepts a pre-encoded base64 user:password string.
expressiveServer()
.withSwagger(
{ config: swaggerDoc },
getBasicAuthMiddleware(process.env.SWAGGER_AUTH!, 'API Docs'),
)silently
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.
// fire-and-forget without crashing the process
silently(() => sendAnalyticsEvent(req));
silently(async () => await notifySlack('Server started'));Logging
Expressive does not bundle a logger. Instead, bootstrap accepts any object that satisfies the Logger interface:
export type Logger = {
info(message: string, ...args: any[]): void;
error(message: string | Error | unknown, ...args: any[]): void;
warn(message: string, ...args: any[]): void;
debug(message: string, ...args: any[]): void;
};This means you can pass console directly, or plug in any logging library (Winston, Pino, etc.):
bootstrap({ logger: console });The @extk/logger-cloudwatch package from the same org is a drop-in fit:
import { getCloudwatchLogger, getConsoleLogger } from '@extk/logger-cloudwatch';
// development
bootstrap({ logger: getConsoleLogger() });
// production — streams structured JSON logs to AWS CloudWatch
bootstrap({
logger: getCloudwatchLogger({
aws: {
region: 'us-east-1',
logGroup: '/my-app/production',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
},
}),
});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
