Express API to Postman collection without Swagger
Export every route of an Express app to a Postman collection, with zod bodies, multer uploads and JWT login, without swagger-jsdoc or a running server. Real output included.
Express is the most popular Node.js framework, and it has no route table you can export. app._router.stack is private and changed in Express 5. swagger-jsdoc means writing a YAML block above every route, and route printers like express-list-endpoints give you paths, not requests.
routeman-cli reads your routers the way Express would mount them, and writes a Postman collection with bodies, auth and environments. Here it is on a small shop API.
The app
Three routers mounted under /api/v1, a JWT middleware, zod on the product routes and plain destructuring on the order routes:
// src/server.js
app.use('/api/v1/auth', authRouter);
app.use('/api/v1/products', productsRouter);
app.use('/api/v1/orders', ordersRouter);
app.get('/health', (req, res) => res.json({ ok: true }));
app.listen(process.env.PORT || 3000);
// src/routes/products.js
const productSchema = z.object({
name: z.string().min(1).max(120),
price: z.number().positive(),
sku: z.string().regex(/^[A-Z]{3}-\d{4}$/),
category: z.enum(['books', 'electronics', 'clothing']),
stock: z.number().int().min(0).default(0),
});
router.post('/', requireAuth, async (req, res) => {
const data = productSchema.parse(req.body);
res.status(201).json(await createProduct(data));
});
router.post('/:productId/image', requireAuth, upload.single('image'), uploadImage);
// src/routes/orders.js
router.use(requireAuth);
router.post('/', async (req, res) => {
const { productId, quantity, couponCode } = req.body;
res.status(201).json(await placeOrder(req.user.sub, productId, Number(quantity), couponCode));
});
requireAuth calls jwt.verify on the Bearer token, and .env sets PORT=4000.
See what routeman-cli finds
Start with routes. It's a dry run that changes nothing:
$ npx routeman-cli routes
POST /api/v1/auth/login json: email, password
POST /api/v1/auth/register json: email, password, name
GET /api/v1/orders π
POST /api/v1/orders π json: productId, quantity, couponCode
PATCH /api/v1/orders/{orderId}/cancel π
GET /api/v1/products
POST /api/v1/products π json: name, price, sku, category, stock
DELETE /api/v1/products/{productId} π
GET /api/v1/products/{productId}
PUT /api/v1/products/{productId} π json: name, price, sku, category, stock
POST /api/v1/products/{productId}/image π form: image
GET /health
12 routes, auth: bearer (login: POST /api/v1/auth/login)
Every mount prefix is applied. Auth follows the middleware exactly: requireAuth on single product routes, and router.use(requireAuth) on every order route. The multer upload is a form field.
Generate
$ npx routeman-cli
β express: 12 requests (5 POST, 4 GET, 1 PUT, 1 DELETE, 1 PATCH)
β auth: bearer (login: POST /api/v1/auth/login)
β wrote postman/shop-api.postman_collection.json
β wrote postman/shop-api.local.postman_environment.json
done in 0.08s - import the files in Postman (File β Import)
Here is the Create product request as it appears in the collection. The values satisfy the zod schema, including the regex and the enum:
{
"name": "John Doe",
"price": 100,
"sku": "AAA-1111",
"category": "books",
"stock": 0
}
Its description holds a field table generated from the schema. category lists the three allowed values, stock is optional because of its default, and the source location is src/routes/products.js:29.
The Create order body, {"productId": "string", "quantity": 1, "couponCode": "123456"}, was read from the destructuring in the handler. The Number(quantity) cast made quantity a number. Its description starts with a note that the fields were inferred from the code. Add a zod schema to that route, and the next run will use it instead.
Log in once, then send everything
Select the local environment (base_url is http://localhost:4000, from .env), fill in username and password, and send Auth βΊ Login. Its test script, generated by routeman-cli, finds the token wherever it is in the response:
const access = find(body, ['access_token', 'accessToken', 'access', 'token', 'jwt', 'id_token', 'idToken', 'key', 'auth_token', 'authToken'], 0);
const refresh = find(body, ['refresh_token', 'refreshToken', 'refresh'], 0);
if (access) { pm.environment.set('access_token', access); pm.collectionVariables.set('access_token', access); }
Every protected request sends Bearer {{access_token}} from then on. {{productId}} and {{orderId}} live in the environment, so set them once from a list response.
Keep it in sync
Add a script to package.json and run it whenever routes change. Collection and request ids are stable, so re-importing replaces the collection in Postman instead of duplicating it:
{
"scripts": {
"postman": "routeman -e staging=https://staging.example.com"
},
"devDependencies": {
"routeman-cli": "^0.1.0"
}
}
For TypeScript projects nothing changes. routeman-cli parses .ts directly and resolves tsconfig paths. If the entry file isn't in package.json's main or scripts, add --entry src/server.ts.