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
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,11 @@ await app.register(swagger, {

app.get('/users', {
schema: {
// Short Postman item title. Use an OpenAPI extension field because
// plain `name` is not emitted into OpenAPI by @fastify/swagger.
'x-pman-name': 'List users',
tags: ['Users'],
summary: 'List users',
summary: 'List users in the current workspace',
response: { 200: { type: 'array' } },
},
}, async () => []);
Expand All @@ -57,6 +60,24 @@ await app.register(pman, {
await app.listen({ port: 3000 });
```

### Postman item titles and docs

OpenAPI `summary` strings are often long, but they make poor Postman request titles. Set a short **OpenAPI extension** on the operation to control the Postman item name:

- `schema['x-pman-name']` (recommended)
- `schema['x-name']` (also supported)

Use `summary` for the first paragraph of the generated Postman “Docs” text.

Postman stores request documentation on the **request** (`item.request.description`) in Collection v2.1; pman writes the same text to `item.description` as well for compatibility.

| Route schema field | How it is used in Postman |
|--------------------|---------------------------|
| `x-pman-name` / `x-name` | Request title (short) |
| `summary` | First paragraph in the item description, followed by auto-generated route metadata |

If `x-pman-name` / `x-name` is omitted, the title falls back to `METHOD <lastPathSegment>` (for example `GET users`).

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.
Expand Down
87 changes: 77 additions & 10 deletions src/merge-collection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { FolderedRoute } from './folders.js';
import { postmanRequestToRouteKey } from './route-id.js';
import { normalizeOpenApiPath, postmanRequestToRouteKey } from './route-id.js';

type PmItem = Record<string, unknown>;

Expand Down Expand Up @@ -100,6 +100,69 @@ function flattenGeneratedByRouteKey(collection: Record<string, unknown>): Map<st
return map;
}

function defaultPostmanName(op: FolderedRoute): string {
const p = normalizeOpenApiPath(op.path);
const segs = p.split('/').filter(Boolean);
const last = segs[segs.length - 1];
if (last) {
return `${op.method} ${last}`;
}
return op.routeKey;
}

function buildReqDescription(op: FolderedRoute): string {
const head =
typeof op.summary === 'string' && op.summary.trim()
? op.summary.trim()
: 'No summary set for this route.';

const p = normalizeOpenApiPath(op.path);
const tags = op.tags.length ? op.tags.join(', ') : '—';
const body = [
'---',
'Generated by @st3ix/pman (do not edit the summary line above if you want stable docs sync).',
'',
'Route',
`- Key: \`${op.routeKey}\``,
`- Path: \`${p}\``,
`- Method: \`${op.method}\``,
`- Tags: ${tags}`,
].join('\n');

return `${head}\n\n${body}`.trim();
}

function shouldPrependSummaryToDescription(summary: string | undefined, description: string): boolean {
if (typeof summary !== 'string' || !summary.trim()) return false;
const s = summary.trim();
const d = description.trim();
if (!d) return true;
if (d === s) return false;
if (d.startsWith(s + '\n')) return false;
if (d.startsWith(s + '\r\n')) return false;
return true;
}

function readRequestDescriptionText(item: PmItem | undefined): string | undefined {
if (!item) return undefined;
const req = item.request;
if (!isRecord(req)) return undefined;
const d = req.description;
if (typeof d === 'string') return d;
if (d && typeof d === 'object' && 'content' in d && typeof (d as { content?: unknown }).content === 'string') {
return String((d as { content: string }).content);
}
return undefined;
}

function writeRequestAndItemDescription(item: PmItem, text: string): void {
// Postman "Docs" for a request is backed by the request's description in Collection v2.1
// (the converter may leave `item.description` unset).
const req = (isRecord(item.request) ? (item.request as Record<string, unknown>) : {}) as Record<string, unknown>;
item.request = { ...req, description: text };
item.description = text;
}

function mergeRequest(
existing: PmItem | undefined,
generated: PmItem,
Expand All @@ -108,16 +171,19 @@ function mergeRequest(
const merged = deepClone(generated) as PmItem;
const genReq = (merged.request as Record<string, unknown> | undefined) ?? {};
merged.request = { ...genReq };
if (typeof op.summary === 'string' && op.summary.trim()) {
merged.name = op.summary.trim();
}
merged.name = typeof op.name === 'string' && op.name.trim() ? op.name.trim() : defaultPostmanName(op);
if (existing) {
const oldDesc = existing.description;
const newDesc = merged.description;
if (typeof oldDesc === 'string' && oldDesc.trim()) {
merged.description = oldDesc;
} else if (newDesc === undefined && typeof op.summary === 'string') {
merged.description = op.summary;
const oldItemDesc = typeof existing.description === 'string' ? existing.description : '';
const oldReqDesc = readRequestDescriptionText(existing) ?? '';
const oldDesc = (oldItemDesc.trim() || oldReqDesc.trim() ? oldItemDesc || oldReqDesc : '').trim();
if (oldDesc) {
if (shouldPrependSummaryToDescription(op.summary, oldDesc)) {
writeRequestAndItemDescription(merged, `${op.summary?.trim() ?? ''}\n\n${oldDesc}`.trim());
} else {
writeRequestAndItemDescription(merged, oldDesc);
}
} else {
writeRequestAndItemDescription(merged, buildReqDescription(op));
}
if (Array.isArray(existing.event)) merged.event = deepClone(existing.event);
if (Array.isArray(existing.response) && existing.response.length > 0) {
Expand All @@ -127,6 +193,7 @@ function mergeRequest(
}
} else {
delete merged.response;
writeRequestAndItemDescription(merged, buildReqDescription(op));
}
merged._pman = { routeId: op.routeId };
return merged;
Expand Down
24 changes: 23 additions & 1 deletion src/openapi-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ export type OpenApiOperationRef = {
path: string;
tags: string[];
summary: string | undefined;
/**
* Short Postman item title.
*
* Prefer `schema['x-pman-name']` or `schema['x-name']` in Fastify route schemas — these are emitted
* as OpenAPI operation extensions and are readable here. Plain `schema.name` is not emitted by @fastify/swagger.
*/
name: string | undefined;
};

export function listOpenApiOperations(spec: Record<string, unknown>): OpenApiOperationRef[] {
Expand All @@ -18,16 +25,31 @@ export function listOpenApiOperations(spec: Record<string, unknown>): OpenApiOpe
for (const [method, operation] of Object.entries(pathItem as Record<string, unknown>)) {
if (!isHttpMethod(method)) continue;
if (!operation || typeof operation !== 'object') continue;
const op = operation as { operationId?: string; tags?: unknown; summary?: string };
const op = operation as {
operationId?: string;
tags?: unknown;
summary?: string;
name?: string;
'x-name'?: unknown;
'x-pman-name'?: unknown;
};
const tags = Array.isArray(op.tags) ? op.tags.map((t) => String(t)) : [];
const rk = routeKey(method, pathKey);
const extNameRaw =
(typeof op['x-pman-name'] === 'string' && op['x-pman-name'].trim() ? op['x-pman-name'] : undefined) ||
(typeof op['x-name'] === 'string' && op['x-name'].trim() ? op['x-name'] : undefined);
const extName = extNameRaw ? String(extNameRaw).trim() : undefined;
// NOTE: `schema.name` in Fastify is not represented as OpenAPI `name` in @fastify/swagger output,
// but `x-…` OpenAPI extension fields on the operation are preserved.
const displayName = typeof op.name === 'string' && op.name.trim() ? op.name.trim() : extName;
out.push({
routeId: buildRouteId(method, pathKey, op),
routeKey: rk,
method: method.toUpperCase(),
path: pathKey,
tags,
summary: typeof op.summary === 'string' ? op.summary : undefined,
name: displayName,
});
}
}
Expand Down
51 changes: 51 additions & 0 deletions test/merge-collection.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ test('merge preserves events and marks _pman', () => {
method: 'GET',
path: '/users',
tags: [],
name: undefined,
summary: 'List users',
folder: 'Users',
},
Expand All @@ -52,4 +53,54 @@ test('merge preserves events and marks _pman', () => {
assert.match(flat, /listUsers/);
assert.match(flat, /pm\.test/);
assert.match(flat, /"X"/);
assert.match(flat, /"name":"GET users"/);
assert.match(flat, /"description":"List users/);
assert.match(flat, /`GET \/users`/);
});

test('merge uses pman display name; summary leads docs', () => {
const existing = shellCollection('API');
existing.item = [
{
name: 'Company',
item: [
{
name: 'old',
request: { method: 'POST', url: { path: ['invites', 'accept'] } },
_pman: { routeId: 'acceptInvite' },
},
],
},
];

const generated = shellCollection('gen');
generated.item = [
{
name: 'Company',
item: [
{
name: 'Long summary title from converter',
request: { method: 'POST', url: { path: ['invites', 'accept'] } },
},
],
},
];

const routes = [
{
routeId: 'acceptInvite',
routeKey: 'POST /invites/accept',
method: 'POST',
path: '/invites/accept',
tags: ['Company'],
name: 'Accept invite',
summary: 'Accept organization invitation; invitee only',
folder: 'Company',
},
];

const merged = mergeOpenApiIntoPostmanCollection({ existing, generated, routes });
const flat = JSON.stringify(merged);
assert.match(flat, /"name":"Accept invite"/);
assert.match(flat, /Accept organization invitation; invitee only/);
});
35 changes: 35 additions & 0 deletions test/openapi-routes.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { listOpenApiOperations } from '../dist/openapi-routes.js';

test('listOpenApiOperations reads x-pman-name and x-name', () => {
const spec = {
paths: {
'/a': {
get: { summary: 'S', 'x-pman-name': 'A' },
},
'/b': {
get: { summary: 'S', 'x-name': 'B' },
},
},
};

const ops = listOpenApiOperations(spec);
const a = ops.find((o) => o.path === '/a' && o.method === 'GET');
const b = ops.find((o) => o.path === '/b' && o.method === 'GET');
assert.equal(a?.name, 'A');
assert.equal(b?.name, 'B');
});

test('listOpenApiOperations prefers x-pman-name over x-name', () => {
const spec = {
paths: {
'/c': {
get: { summary: 'S', 'x-pman-name': 'P', 'x-name': 'N' },
},
},
};
const ops = listOpenApiOperations(spec);
const c = ops.find((o) => o.path === '/c' && o.method === 'GET');
assert.equal(c?.name, 'P');
});
Loading