Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 57 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@

Sync Fastify route schemas to Postman automatically.

You keep writing normal Fastify routes with JSON Schema. This plugin reads the OpenAPI spec from `@fastify/swagger`, converts it with `openapi-to-postmanv2`, merges changes into an existing Postman collection (preserving your tests/examples), and pushes updates through the Postman API so Postman Desktop picks them up via cloud sync.
You keep writing normal Fastify routes with JSON Schema. This plugin reads the OpenAPI spec from `@fastify/swagger`, converts it with `openapi-to-postmanv2`, merges changes into an existing Postman collection (preserving your tests and scripts), and pushes updates through the Postman API so Postman Desktop picks them up via cloud sync.

## Pain points this plugin addresses

- **Long OpenAPI `summary` text** is great for documentation but a poor default for Postman request titles. pman uses **`x-pman-name` / `x-name`** (OpenAPI operation extensions) for short item names and still puts the full `summary` in the request description (“Docs”).

- **Monolithic or misleading folder layout** when grouping only by a single path segment (or by tags) makes large APIs hard to browse. With **`folderStrategy: 'path'`** (or **`'hybrid'`**) and **`pathFolderNesting: 'nested'`** (default), URL prefixes become **nested Postman folders**—for example `POST /auth/user/admin/create` is grouped as **Auth → User → Admin**, with the request under **Admin**.

- **Stale or duplicate folders after you change strategy or upgrade** pman. The sync state tracks managed trees with **path keys** (e.g. `Auth>User>Admin`) and, on each merge, **derives extra removal keys** from the current routes (e.g. first path segment, first tag) so older layouts (tag-only top-level folders, flat path folders from earlier releases) are still removed when you sync.

## Requirements

Expand Down Expand Up @@ -53,6 +61,9 @@ await app.register(pman, {
postmanBaseUrl: 'http://127.0.0.1:3000',
collectionName: 'My API',
folderStrategy: 'path',
// Default: one Postman subfolder per path prefix segment, e.g. /auth/.../... → Auth → …
// Use 'flat' to only use the first segment as a single folder (legacy style).
pathFolderNesting: 'nested',
// Optional explicit auth config:
// auth: { type: 'apiKey', headerKey: 'X-API-Token', variableKey: 'apiToken' },
});
Expand Down Expand Up @@ -80,20 +91,48 @@ If `x-pman-name` / `x-name` is omitted, the title falls back to `METHOD <lastPat

Pass **`postmanApiKey`** and **`workspaceId`** in the same object as the rest of the plugin options (recommended for apps you control). If either value is omitted or an empty string, the plugin falls back to `POSTMAN_API_KEY` / `POSTMAN_WORKSPACE_ID`.

**`postmanBaseUrl`** defines the Postman collection variable **`baseUrl`**, so requests that use `{{baseUrl}}` resolve correctly. If you omit it (and `POSTMAN_BASE_URL`), the first OpenAPI **`servers[].url`** is used.
| Route schema field | How it is used in Postman |
|--------------------|---------------------------|
| `x-pman-name` (recommended) or `x-name` | Request title (short) |
| `summary` | First part of the item description (Docs), plus auto-generated route metadata |

**`reuseExistingCollectionByName`** (default `true`): if there is no `.postman-sync.json` yet, the workspace is searched for a collection whose name equals **`collectionName`**; that collection is reused instead of creating a duplicate. Set to `false` to always create a new collection when no state file exists.
If `x-pman-name` / `x-name` is omitted, the title falls back to something like `GET users` (method plus last path segment).

On `onReady`, the plugin reads the OpenAPI document, converts it, merges it into the Postman collection, and pushes changes. The first successful run creates a collection and stores its uid in `.postman-sync.json` (override with `statePath`).
Postman stores primary documentation on the **request** (`item.request.description`); pman also writes the same text to `item.description` for compatibility with tools that only read the folder-level field.

### Folder layout in Postman

| `folderStrategy` | Behaviour |
|------------------|------------|
| `path` (default) | Folders follow the route URL (after optional `folderPathStripPrefix`). |
| `tags` | One folder per first OpenAPI tag (or `Untagged`). |
| `hybrid` | Tag when present; otherwise same as `path` for that operation. |

### Environment variables (optional fallback)
**Path nesting** applies when `folderStrategy` is `path` or `hybrid` and **`pathFolderNesting`** is set:

| `pathFolderNesting` | Behaviour |
|---------------------|------------|
| `nested` (default) | Each path **prefix** segment (except the last) becomes a folder, title-cased. Example: `POST /auth/user/admin/create` → folders **Auth → User → Admin**, request under **Admin**. |
| `flat` | Only the first segment (or the tag/hybrid resolution) is used as a **single** folder name—closer to older single-folder behaviour. |

Use `folderPathStripPrefix` to ignore a common API base (e.g. `/v1`) before computing segments.

**Environment variables (optional fallback)**

| Variable | Purpose |
|----------|---------|
| `POSTMAN_API_KEY` | Used when `postmanApiKey` is not set or is blank |
| `POSTMAN_WORKSPACE_ID` | Used when `workspaceId` is not set or is blank |
| `POSTMAN_BASE_URL` | Used when `postmanBaseUrl` is not set or is blank |

Pass **`postmanApiKey`** and **`workspaceId`** in the same object as the rest of the plugin options (recommended for apps you control). If either value is omitted or is an empty string, the plugin falls back to the environment variables above.

**`postmanBaseUrl`** defines the Postman collection variable **`baseUrl`**, so requests that use `{{baseUrl}}` resolve correctly. If you omit it (and `POSTMAN_BASE_URL`), the first OpenAPI **`servers[].url`** is used.

**`reuseExistingCollectionByName`** (default `true`): if there is no `.postman-sync.json` yet, the workspace is searched for a collection whose name equals **`collectionName`**; that collection is reused instead of creating a duplicate. Set to `false` to always create a new collection when no state file exists.

On `onReady`, the plugin reads the OpenAPI document, converts it, merges it into the Postman collection, and pushes changes. The first successful run creates a collection and stores its uid in `.postman-sync.json` (override with `statePath`).

Secrets are never written to the sync state file.

### Options
Expand All @@ -108,30 +147,34 @@ Secrets are never written to the sync state file.
| `collectionName` | Collection title (default `Fastify (pman)`) |
| `statePath` | Path to JSON state file (default `.postman-sync.json` in `cwd`) |
| `dryRun` | If `true`, no Postman HTTP calls are made |
| `folderStrategy` | `path` (default), `tags`, or `hybrid` — controls folder layout in Postman |
| `folderPathStripPrefix` | Strip this URL prefix before using the first segment as a folder (`path` / `hybrid`) |
| `folderStrategy` | `path` (default), `tags`, or `hybrid` — how routes are grouped into Postman folders |
| `folderPathStripPrefix` | Strip this URL prefix (normalized like OpenAPI) before path segments are used for folders (`path` / `hybrid`) |
| `pathFolderNesting` | `nested` (default) or `flat` — see [Folder layout in Postman](#folder-layout-in-postman) |
| `postmanApiBase` | Override Postman API base URL |
| `fetchImpl` | Custom `fetch` (for tests) |

If the API key and workspace id are both missing (options and env), the plugin logs a warning and skips sync so local development still works.

If **`.postman-sync.json`** points to a collection that was **deleted in Postman** (HTTP 404), the plugin **removes that state file**, then tries **reuse by name** again or **creates** a new collection — you should not see a hard failure for a stale uid anymore.
If **`.postman-sync.json`** points to a collection that was **deleted in Postman** (HTTP 404), the plugin **removes that state file**, then tries **reuse by name** again or **creates** a new collection so a stale collection uid does not hard-fail the app.

### Merge behaviour

Requests managed by this plugin are tagged with `_pman.routeId`. On each sync, spec-driven fields are refreshed from OpenAPI while `event` (tests, prerequest) is copied from the previous collection item when present. **`response`** saved examples are kept only if that request already had a non-empty `response` array in Postman; otherwise OpenAPI-generated example responses are not written (avoids clutter from the converter’s defaults).
- Requests managed by this plugin are tagged with `_pman.routeId`. On each sync, spec-driven fields are refreshed from OpenAPI while **`event`** (tests, prerequest) is copied from the previous collection item when present.
- **`response`** saved examples are kept only if that request already had a non-empty `response` array in Postman; otherwise OpenAPI-generated example responses are not written (avoids clutter from the converter defaults).
- The sync state file stores **`managedFolders`**: one **path key** per managed route group (e.g. `Auth>User>Admin`) so the correct **nested** folder tree can be replaced on the next run even if many folders share a short name in different areas of the tree.
- In addition, each merge **computes removal aliases** from the current route list (full path key, first URL segment, first tag) so **legacy** collection shapes from earlier pman versions (for example a single top-level `Users` tag folder) are still removed after you switch to path-based nested folders or upgrade the plugin.

### Tech stack

- **Fastify**: routes + schemas
- **`@fastify/swagger`**: generates OpenAPI (you don't write OpenAPI by hand)
- **`openapi-to-postmanv2`**: converts OpenAPI Postman Collection v2
- **Fastify**: routes and schemas
- **`@fastify/swagger`**: generates OpenAPI
- **`openapi-to-postmanv2`**: converts OpenAPI to Postman Collection v2.1
- **Postman API**: stores the collection in your workspace

## Local Postman smoke test

1. **From repo root:** copy [`.env.example`](.env.example) to `.env` and set `POSTMAN_API_KEY`, `POSTMAN_WORKSPACE_ID`, and optionally `POSTMAN_BASE_URL` (`.env` is gitignored), then run `npm run dev:example` — builds the plugin, loads `.env`, picks a free port if `PORT` is busy, syncs on `onReady`.
2. **From `examples/`:** after `npm run build`, edit [`examples/playground.mjs`](examples/playground.mjs) and fill the `postman` object (`postmanApiKey`, `workspaceId`), then run `node examples/playground.mjs`. The script picks **3030** (or `PORT`) or a free port, sets **`postmanBaseUrl`** to match that port, and uses **`pino-pretty`** for readable logs when the dev dependency is installed.
1. **From the repo root:** copy [`.env.example`](.env.example) to `.env` and set `POSTMAN_API_KEY`, `POSTMAN_WORKSPACE_ID`, and optionally `POSTMAN_BASE_URL` (`.env` is gitignored), then run `npm run dev:example` — builds the plugin, loads `.env`, picks a free port if `PORT` is busy, syncs on `onReady`.
2. **From `examples/`:** after `npm run build`, edit [`examples/playground.mjs`](examples/playground.mjs) and fill the `postman` object (`postmanApiKey`, `workspaceId`), then run `node --env-file=.env examples/playground.mjs` or `node examples/playground.mjs`. The script picks **3030** (or `PORT`) or a free port, sets **`postmanBaseUrl`** to that origin, and uses **`pino-pretty`** for readable logs when the dev dependency is installed. The example uses **`pathFolderNesting: 'nested'`** and includes `POST /auth/user/admin/create` to verify **Auth → User → Admin** in Postman.

### Contributing

Expand Down
72 changes: 63 additions & 9 deletions examples/playground.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,27 @@ import swagger from '@fastify/swagger';
import pman from '../dist/index.js';

/**
* Postman: fill when you run `node examples/playground.mjs` without `.env`.
* Leave blank to use POSTMAN_* from the environment.
* Never commit these values! Make sure before you commit!
* Local manual test for @st3ix/pman (nested path folders + titles/docs).
*
* Run (repo root):
* npm run build && node --env-file=.env examples/playground.mjs
* or:
* npm run dev:example
*
* Env (or fill `postman` below): POSTMAN_API_KEY, POSTMAN_WORKSPACE_ID, optional POSTMAN_BASE_URL.
* Never commit secrets. Do not commit `.postman-sync.json`.
*
* After sync, open the workspace collection in Postman and verify:
* - `folderStrategy: 'path'` + `pathFolderNesting: 'nested'`: URL segments become nested folders.
* Example: POST `/auth/user/admin/create` → folders Auth → User → Admin, request under Admin.
* - `/demo/...` routes sit under Demo → … (e.g. Demo → Users).
* - Short request names from `x-pman-name`; long `summary` text in request Docs.
*/

const postman = {
postmanApiKey: '',
workspaceId: '',
workspaceLink: '',
};

async function pickListenPort(preferred) {
Expand Down Expand Up @@ -75,8 +89,10 @@ async function main() {
'/demo/users',
{
schema: {
// Postman item title: use an OpenAPI extension field (plain `name` is not emitted to OpenAPI by @fastify/swagger)
'x-pman-name': 'List users',
tags: ['Users'],
summary: 'List demo users',
summary: 'List demo users (playground) — this sentence should be the first line in Postman "Docs"',
response: { 200: { type: 'array', items: { type: 'string' } } },
},
},
Expand All @@ -87,8 +103,9 @@ async function main() {
'/demo/users/:id',
{
schema: {
'x-pman-name': 'Get user',
tags: ['Users'],
summary: 'Get demo user',
summary: 'Get a single demo user by id from the in-memory list',
params: {
type: 'object',
properties: { id: { type: 'string' } },
Expand All @@ -104,8 +121,9 @@ async function main() {
'/demo/users',
{
schema: {
'x-pman-name': 'Create user',
tags: ['Users'],
summary: 'Create demo user',
summary: 'Create a demo user with a `name` field (201 response included for Postman)',
body: {
type: 'object',
properties: { name: { type: 'string' } },
Expand All @@ -126,24 +144,54 @@ async function main() {
async (req, reply) => reply.code(201).send({ created: true, name: req.body.name }),
);

fastify.post(
'/auth/user/admin/create',
{
schema: {
'x-pman-name': 'Create admin',
summary: 'Path-nested demo: expect Postman folders Auth → User → Admin (folderStrategy: path).',
response: { 201: { type: 'object', additionalProperties: true } },
},
},
async (_req, reply) => reply.code(201).send({ created: true }),
);

fastify.get(
'/demo/posts',
{
schema: {
// Intentionally no `x-pman-name` / `x-name`: Postman title should fall back to something like `GET posts`.
tags: ['Posts'],
summary: 'List demo posts',
summary: 'List all demo posts (this long summary should not become the request title)',
response: { 200: { type: 'array', items: { type: 'object' } } },
},
},
async () => [{ id: 'p1', title: 'Hello' }],
);

fastify.post(
'/demo/company/invites/accept',
{
schema: {
// Mirrors the "long summary" pain case: `x-pman-name` keeps Postman titles short.
'x-pman-name': 'Accept invite',
tags: ['Company'],
summary:
'Accept organization invitation (Better Auth); invitee only — this line should be the first paragraph in Postman docs, not the request title',
body: { type: 'object', additionalProperties: true },
response: { 200: { type: 'object' } },
},
},
async () => ({ ok: true }),
);

fastify.delete(
'/demo/posts/:id',
{
schema: {
'x-pman-name': 'Delete post',
tags: ['Posts'],
summary: 'Delete demo post',
summary: 'Delete a demo post by id (returns 204)',
params: {
type: 'object',
properties: { id: { type: 'string' } },
Expand All @@ -158,12 +206,18 @@ async function main() {
await fastify.register(pman, {
postmanApiKey: postman.postmanApiKey,
workspaceId: postman.workspaceId,
workspaceLink: postman.workspaceLink,
postmanBaseUrl: publicBase,
collectionName: 'pman ~ by st3ix',
folderStrategy: 'path',
pathFolderNesting: 'nested',
});

await fastify.listen({ port, host: '127.0.0.1' });
fastify.log.info(`Listening at ${publicBase} (Postman baseUrl matches this port)`);
fastify.log.info(
{ publicBase, folderStrategy: 'path', pathFolderNesting: 'nested' },
'pman: listening — check Postman for nested folders (e.g. Auth/User/Admin for POST /auth/user/admin/create)',
);
}

main().catch((err) => {
Expand Down
49 changes: 42 additions & 7 deletions src/folders.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { FolderStrategy } from './options.js';
import type { FolderStrategy, PathFolderNesting } from './options.js';
import type { OpenApiOperationRef } from './openapi-routes.js';
import { normalizeOpenApiPath } from './route-id.js';

Expand All @@ -24,30 +24,65 @@ function folderFromPath(path: string, stripPrefix?: string): string {
return titleCaseSegment(first.replace(/[{}]/g, ''));
}

function pathFolderSegments(path: string, stripPrefix?: string): string[] {
let p = normalizeOpenApiPath(path);
if (stripPrefix) {
const sp = normalizeOpenApiPath(stripPrefix);
if (p === sp || p.startsWith(`${sp}/`)) {
p = p.slice(sp.length) || '/';
if (!p.startsWith('/')) p = `/${p}`;
}
}
const segments = p.split('/').filter(Boolean);
if (segments.length <= 1) return [];
return segments.slice(0, -1).map((s) => titleCaseSegment(s.replace(/[{}]/g, '')));
}

export function folderNameForOperation(
op: OpenApiOperationRef,
strategy: FolderStrategy,
stripPrefix?: string,
pathFolderNesting: PathFolderNesting = 'nested',
): string {
if (strategy === 'tags' || strategy === 'hybrid') {
const tag = op.tags[0];
if (tag) return titleCaseSegment(tag);
if (strategy === 'hybrid') return folderFromPath(op.path, stripPrefix);
if (strategy === 'hybrid') {
if (pathFolderNesting === 'nested') {
const segs = pathFolderSegments(op.path, stripPrefix);
return (segs[0] ?? folderFromPath(op.path, stripPrefix)) || 'Root';
}
return folderFromPath(op.path, stripPrefix);
}
return 'Untagged';
}
if (pathFolderNesting === 'nested') {
const segs = pathFolderSegments(op.path, stripPrefix);
return (segs[0] ?? folderFromPath(op.path, stripPrefix)) || 'Root';
}
return folderFromPath(op.path, stripPrefix);
}

export type FolderedRoute = OpenApiOperationRef & { folder: string };
export type FolderedRoute = OpenApiOperationRef & { folder: string; folderPath: string[] };

export function attachFolders(
ops: OpenApiOperationRef[],
strategy: FolderStrategy,
stripPrefix?: string,
pathFolderNesting: PathFolderNesting = 'nested',
): FolderedRoute[] {
return ops.map((op) => ({
...op,
folder: folderNameForOperation(op, strategy, stripPrefix),
}));
return ops.map((op) => {
const root = folderNameForOperation(op, strategy, stripPrefix, pathFolderNesting);
let folderPath: string[] = [root];
if ((strategy === 'path' || strategy === 'hybrid') && pathFolderNesting === 'nested') {
const segs = pathFolderSegments(op.path, stripPrefix);
if (segs.length) folderPath = segs;
}
return {
...op,
folder: root,
folderPath,
};
});
}

3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import plugin from './plugin.js';

export default plugin;
export type { FastifyPmanOptions, FolderStrategy } from './options.js';
export type { FastifyPmanOptions, FolderStrategy, PathFolderNesting } from './options.js';
export type { FolderedRoute } from './folders.js';
Loading
Loading