From 20bb27913084888acb4fdb66e2c26c33911f51c4 Mon Sep 17 00:00:00 2001 From: Statsly-org Date: Tue, 21 Apr 2026 19:43:13 +0200 Subject: [PATCH 1/3] 1.0.2 --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index d038af0..cf23bfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@st3ix/pman", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index 51e7b75..07b9e5a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@st3ix/pman", - "version": "1.0.1", + "version": "1.0.2", "description": "Sync Fastify route schemas to Postman via OpenAPI and the Postman API.", "type": "module", "main": "./dist/index.js", From b851dc8e74442d3dfcaae77c22a58579626432c0 Mon Sep 17 00:00:00 2001 From: Statsly-org Date: Sun, 26 Apr 2026 20:08:46 +0200 Subject: [PATCH 2/3] feat(cli): add support command; improve postman request docs --- README.md | 41 ++++++++++++++++- bin/pman.js | 11 +++++ src/merge-collection.ts | 87 +++++++++++++++++++++++++++++++---- src/openapi-routes.ts | 24 +++++++++- src/options.ts | 36 ++++++++++++++- test/merge-collection.test.js | 51 ++++++++++++++++++++ test/openapi-routes.test.js | 35 ++++++++++++++ test/pman-cli.test.js | 36 +++++++++++++++ 8 files changed, 307 insertions(+), 14 deletions(-) create mode 100644 test/openapi-routes.test.js create mode 100644 test/pman-cli.test.js diff --git a/README.md b/README.md index 8dbe570..4229ba3 100644 --- a/README.md +++ b/README.md @@ -35,15 +35,21 @@ 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 () => []); await app.register(pman, { postmanApiKey: 'PMAK-…', - workspaceId: '00000000-0000-4000-8000-000000000000', + // Either pass workspaceId directly... + // workspaceId: '00000000-0000-4000-8000-000000000000', + // ...or pass a workspace link and let pman extract the id: + workspaceLink: 'https://.postman.co/workspace/My~00000000-0000-4000-8000-000000000000/overview', postmanBaseUrl: 'http://127.0.0.1:3000', collectionName: 'My API', folderStrategy: 'path', @@ -54,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 ` (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. @@ -77,6 +101,7 @@ Secrets are never written to the sync state file. | Option | Description | |--------|-------------| | `workspaceId` | Postman workspace id | +| `workspaceLink` | Postman workspace link (extracts `workspaceId` automatically) | | `postmanApiKey` | Postman API key | | `postmanBaseUrl` | Value for Postman variable `baseUrl` (`{{baseUrl}}` in URLs) | | `reuseExistingCollectionByName` | Reuse workspace collection with same name when no state file (default `true`) | @@ -112,6 +137,18 @@ Requests managed by this plugin are tagged with `_pman.routeId`. On each sync, s See [`CONTRIBUTING.md`](CONTRIBUTING.md). +## CLI + +The package ships a small `pman` CLI: + +```bash +pman help +pman clear +pman support +``` + +`pman support` prints the Discord invite: `https://discord.gg/4FBYAMxwdk`. + ## License MIT diff --git a/bin/pman.js b/bin/pman.js index 31bd64e..a16d2a6 100644 --- a/bin/pman.js +++ b/bin/pman.js @@ -1,6 +1,8 @@ #!/usr/bin/env node import { unlink } from 'node:fs/promises'; +const SUPPORT_DISCORD_URL = 'https://discord.gg/4FBYAMxwdk'; + const cmd = process.argv[2]; const statePath = process.argv.includes('--state') ? process.argv[process.argv.indexOf('--state') + 1] @@ -11,6 +13,10 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') { Commands: pman clear [--state ] Deletes the local sync state file (default .postman-sync.json) + pman support Prints the Discord support invite + +Support: + ${SUPPORT_DISCORD_URL} `); process.exit(0); } @@ -32,6 +38,11 @@ if (cmd === 'clear') { process.exit(0); } +if (cmd === 'support') { + process.stdout.write(`${SUPPORT_DISCORD_URL}\n`); + process.exit(0); +} + process.stderr.write(`Unknown command: ${cmd}\n`); process.exit(1); diff --git a/src/merge-collection.ts b/src/merge-collection.ts index c733a36..a05175e 100644 --- a/src/merge-collection.ts +++ b/src/merge-collection.ts @@ -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; @@ -100,6 +100,69 @@ function flattenGeneratedByRouteKey(collection: Record): Map) : {}) as Record; + item.request = { ...req, description: text }; + item.description = text; +} + function mergeRequest( existing: PmItem | undefined, generated: PmItem, @@ -108,16 +171,19 @@ function mergeRequest( const merged = deepClone(generated) as PmItem; const genReq = (merged.request as Record | 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) { @@ -127,6 +193,7 @@ function mergeRequest( } } else { delete merged.response; + writeRequestAndItemDescription(merged, buildReqDescription(op)); } merged._pman = { routeId: op.routeId }; return merged; diff --git a/src/openapi-routes.ts b/src/openapi-routes.ts index 86907b6..16eb496 100644 --- a/src/openapi-routes.ts +++ b/src/openapi-routes.ts @@ -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): OpenApiOperationRef[] { @@ -18,9 +25,23 @@ export function listOpenApiOperations(spec: Record): OpenApiOpe for (const [method, operation] of Object.entries(pathItem as Record)) { 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, @@ -28,6 +49,7 @@ export function listOpenApiOperations(spec: Record): OpenApiOpe path: pathKey, tags, summary: typeof op.summary === 'string' ? op.summary : undefined, + name: displayName, }); } } diff --git a/src/options.ts b/src/options.ts index b98880c..4eb60ad 100644 --- a/src/options.ts +++ b/src/options.ts @@ -4,6 +4,8 @@ export type FolderStrategy = 'path' | 'tags' | 'hybrid'; export type FastifyPmanOptions = { workspaceId?: string; + /** Optional Postman workspace URL. If set, the workspace id is extracted automatically. */ + workspaceLink?: string; collectionName?: string; statePath?: string; dryRun?: boolean; @@ -58,6 +60,36 @@ function firstNonEmpty(...candidates: (string | undefined)[]): string | undefine return undefined; } +function wspacelink(link: string | undefined): string | undefined { + if (typeof link !== 'string') return undefined; + const raw = link.trim(); + if (!raw) return undefined; + + let u: URL; + try { + u = new URL(raw); + } catch { + return undefined; + } + + const parts = u.pathname.split('/').filter(Boolean); + const i = parts.indexOf('workspace'); + if (i < 0) return undefined; + const seg = parts[i + 1]; + if (typeof seg !== 'string' || seg.length === 0) return undefined; + + // Typical format: "~" + const afterTilde = seg.includes('~') ? seg.split('~').pop() : seg; + const id = (afterTilde ?? '').trim(); + if (!id) return undefined; + + // Very small sanity check: UUID-ish. + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) { + return undefined; + } + return id; +} + export function resolvePmanOptions(opts: FastifyPmanOptions): ResolvedPmanOptions { const auth = opts.auth && typeof opts.auth === 'object' @@ -73,8 +105,10 @@ export function resolvePmanOptions(opts: FastifyPmanOptions): ResolvedPmanOption } : null; + const extractedWorkspaceId = wspacelink(opts.workspaceLink); + return { - workspaceId: firstNonEmpty(opts.workspaceId, process.env.POSTMAN_WORKSPACE_ID), + workspaceId: firstNonEmpty(opts.workspaceId, extractedWorkspaceId, process.env.POSTMAN_WORKSPACE_ID), collectionName: opts.collectionName?.trim() || 'Fastify (pman)', statePath: opts.statePath ?? `${process.cwd()}/.postman-sync.json`, dryRun: opts.dryRun ?? false, diff --git a/test/merge-collection.test.js b/test/merge-collection.test.js index df9ac97..b35e21a 100644 --- a/test/merge-collection.test.js +++ b/test/merge-collection.test.js @@ -42,6 +42,7 @@ test('merge preserves events and marks _pman', () => { method: 'GET', path: '/users', tags: [], + name: undefined, summary: 'List users', folder: 'Users', }, @@ -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/); }); diff --git a/test/openapi-routes.test.js b/test/openapi-routes.test.js new file mode 100644 index 0000000..dbcf0cc --- /dev/null +++ b/test/openapi-routes.test.js @@ -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'); +}); diff --git a/test/pman-cli.test.js b/test/pman-cli.test.js new file mode 100644 index 0000000..334ecbb --- /dev/null +++ b/test/pman-cli.test.js @@ -0,0 +1,36 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { text } from 'node:stream/consumers'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const bin = join(root, 'bin', 'pman.js'); + +async function runPman(args) { + const child = spawn(process.execPath, [bin, ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + const outP = text(child.stdout); + const errP = text(child.stderr); + const [code] = await once(child, 'exit'); + const out = await outP; + const err = await errP; + return { code, out, err }; +} + +test('pman help includes Discord support link', async () => { + const { code, out, err } = await runPman(['help']); + assert.equal(code, 0); + assert.equal(err, ''); + assert.match(out, /https:\/\/discord\.gg\/4FBYAMxwdk/); +}); + +test('pman support prints Discord invite', async () => { + const { code, out, err } = await runPman(['support']); + assert.equal(code, 0); + assert.equal(err, ''); + assert.equal(out.trim(), 'https://discord.gg/4FBYAMxwdk'); +}); From 61a30a7a330a45d16c6813f5df926be4f8d92397 Mon Sep 17 00:00:00 2001 From: Statsly-org Date: Sun, 26 Apr 2026 20:25:47 +0200 Subject: [PATCH 3/3] feat: enhance Postman integration with nested folder support and improved documentation handling --- README.md | 82 ++++++++---- examples/playground.mjs | 74 ++++++++-- src/folders.ts | 49 ++++++- src/index.ts | 3 +- src/merge-collection.ts | 246 ++++++++++++++++++++++++++++------ src/options.ts | 6 + src/sync.ts | 7 +- test/merge-collection.test.js | 98 ++++++++++++++ 8 files changed, 479 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index 4229ba3..50a579a 100644 --- a/README.md +++ b/README.md @@ -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 @@ -35,8 +43,7 @@ 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. + // Short Postman item title. Plain `name` is not emitted into OpenAPI by @fastify/swagger. 'x-pman-name': 'List users', tags: ['Users'], summary: 'List users in the current workspace', @@ -53,6 +60,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' }, }); @@ -62,31 +72,33 @@ 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 | +| `x-pman-name` (recommended) or `x-name` | Request title (short) | +| `summary` | First part of the item description (Docs), plus auto-generated route metadata | -If `x-pman-name` / `x-name` is omitted, the title falls back to `METHOD ` (for example `GET users`). +If `x-pman-name` / `x-name` is omitted, the title falls back to something like `GET users` (method plus last path segment). -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`. +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. -**`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. +### Folder layout in Postman -**`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. +| `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. | -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`). +**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) +**Environment variables (optional fallback)** | Variable | Purpose | |----------|---------| @@ -94,6 +106,14 @@ On `onReady`, the plugin reads the OpenAPI document, converts it, merges it into | `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 @@ -108,30 +128,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 diff --git a/examples/playground.mjs b/examples/playground.mjs index 1db8946..51f88ba 100644 --- a/examples/playground.mjs +++ b/examples/playground.mjs @@ -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) { @@ -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' } } }, }, }, @@ -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' } }, @@ -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' } }, @@ -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' } }, @@ -158,12 +206,18 @@ async function main() { await fastify.register(pman, { postmanApiKey: postman.postmanApiKey, workspaceId: postman.workspaceId, + workspaceLink: postman.workspaceLink, postmanBaseUrl: publicBase, - collectionName: 'Shiftr / pman playground', + 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) => { diff --git a/src/folders.ts b/src/folders.ts index a499ccd..25bd67b 100644 --- a/src/folders.ts +++ b/src/folders.ts @@ -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'; @@ -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, + }; + }); } diff --git a/src/index.ts b/src/index.ts index 0246561..a8d2547 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/merge-collection.ts b/src/merge-collection.ts index a05175e..0826c80 100644 --- a/src/merge-collection.ts +++ b/src/merge-collection.ts @@ -7,6 +7,115 @@ function uniqueSorted(values: T[]): T[] { return [...new Set(values)].sort((a, b) => String(a).localeCompare(String(b))); } +function pathEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +} + +function pathIsPrefixOrEqual(short: string[], long: string[]): boolean { + if (short.length > long.length) return false; + for (let i = 0; i < short.length; i += 1) { + if (short[i] !== long[i]) return false; + } + return true; +} + +/** Compact key for state.json `managedFolders` (e.g. Auth>User>Admin). */ +export function managedFolderStateKeyForPath(path: string[]): string { + return path.filter((s) => s.length > 0).join('>'); +} + +function titleCaseSegment(seg: string): string { + const s = seg.trim(); + if (!s) return ''; + return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(); +} + +/** + * Keys used to strip prior pman folder trees from the collection. + * Includes: state file entries (new `A>B>C` and legacy one-segment) plus + * per-route aliases for layouts from older releases (root segment, tag-based folders). + */ +function allFolderRemovalKeys( + stateKeys: string[] | undefined, + routes: FolderedRoute[], +): string[] { + const out = new Set(); + for (const raw of stateKeys ?? []) { + const k = String(raw).trim(); + if (k) out.add(k); + } + for (const r of routes) { + out.add(managedFolderStateKeyForPath(r.folderPath)); + const rootSeg = r.folderPath[0] ?? r.folder; + if (rootSeg) out.add(rootSeg); + const tag0 = r.tags[0]; + if (typeof tag0 === 'string' && tag0.trim()) { + out.add(titleCaseSegment(tag0)); + } + } + return uniqueSorted([...out]); +} + +function pathMatchesAnyManagedStateKey( + path: string[], + statePaths: string[][], +): boolean { + for (const sp of statePaths) { + if (pathEqual(path, sp) || pathIsPrefixOrEqual(path, sp) || pathIsPrefixOrEqual(sp, path)) { + return true; + } + } + return false; +} + +function filterOutStateManagedFolderTrees( + items: unknown[] | undefined, + stateKeys: string[] | null, + parentPath: string[], +): unknown[] { + if (!Array.isArray(items) || !stateKeys || stateKeys.length === 0) { + return Array.isArray(items) ? [...(items as unknown[])] : []; + } + const statePaths = stateKeys.map((k) => k.split('>').filter(Boolean)); + const out: unknown[] = []; + for (const raw of items) { + if (!isRecord(raw)) continue; + const it = raw as PmItem; + if (it.request && isRecord(it.request)) { + out.push(it); + continue; + } + if (Array.isArray(it.item)) { + const name = typeof it.name === 'string' ? it.name : ''; + if (!name) { + out.push(it); + continue; + } + const herePath = [...parentPath, name]; + if (pathMatchesAnyManagedStateKey(herePath, statePaths)) { + continue; + } + const pman = it._pman; + const isManaged = + isRecord(pman) && pman.folderManaged === true + ? true + : !isRecord(pman) || pman.folderManaged !== false; + if (!isManaged) { + out.push(it); + continue; + } + const nextChildren = filterOutStateManagedFolderTrees(it.item as unknown[], stateKeys, herePath); + if (nextChildren.length === 0) continue; + out.push({ ...it, item: nextChildren }); + } + } + return out; +} + function buildCreditsText(): string { return [ 'Fastify → Postman sync via @st3ix/pman.', @@ -199,24 +308,98 @@ function mergeRequest( return merged; } -function buildFolderItems( - itemsByFolder: Map, - opsByFolder: Map, -): PmItem[] { - const folders = [...itemsByFolder.keys()].sort((a, b) => a.localeCompare(b)); - const root: PmItem[] = []; - for (const name of folders) { - const items = itemsByFolder.get(name); - if (!items?.length) continue; - const ops = opsByFolder.get(name) ?? []; - root.push({ +type RouteGroup = { path: string[]; items: PmItem[]; ops: FolderedRoute[] }; + +function prefixMatchesPath(prefix: string[], full: string[]): boolean { + if (full.length < prefix.length) return false; + for (let i = 0; i < prefix.length; i += 1) { + if (full[i] !== prefix[i]) return false; + } + return true; +} + +type FolderTree = { + name: string; + children: Map; + leafItems: PmItem[]; + leafOps: FolderedRoute[]; +}; + +function getOrCreateChild(tree: FolderTree, name: string): FolderTree { + const existing = tree.children.get(name); + if (existing) return existing; + const next: FolderTree = { name, children: new Map(), leafItems: [], leafOps: [] }; + tree.children.set(name, next); + return next; +} + +function buildFolderTree(groups: Map): FolderTree { + const root: FolderTree = { name: '', children: new Map(), leafItems: [], leafOps: [] }; + for (const g of groups.values()) { + if (!g.path.length) continue; + let cur = root; + for (const seg of g.path) { + cur = getOrCreateChild(cur, seg); + } + cur.leafItems.push(...g.items); + cur.leafOps.push(...g.ops); + } + + // de-dupe by routeId in case the same op got grouped twice (should not happen) + for (const t of walkTrees(root)) { + if (!t.leafItems.length) continue; + const seen = new Set(); + const items: PmItem[] = []; + const ops: FolderedRoute[] = []; + for (let i = 0; i < t.leafItems.length; i += 1) { + const it = t.leafItems[i]; + const op = t.leafOps[i]; + const id = op.routeId; + if (seen.has(id)) continue; + seen.add(id); + items.push(it); + ops.push(op); + } + t.leafItems = items; + t.leafOps = ops; + } + return root; +} + +function* walkTrees(tree: FolderTree): Generator { + yield tree; + for (const c of tree.children.values()) yield* walkTrees(c); +} + +function renderFolderTree(node: FolderTree, allRoutes: FolderedRoute[], prefix: string[]): PmItem[] { + const out: PmItem[] = []; + + const childNames = [...node.children.keys()].sort((a, b) => a.localeCompare(b)); + for (const name of childNames) { + const child = node.children.get(name); + if (!child) continue; + + const childPrefix = [...prefix, name]; + const nestedFolders = renderFolderTree(child, allRoutes, childPrefix); + const directItems = child.leafItems; + const childItems = [...nestedFolders, ...directItems]; + if (!childItems.length) continue; + + const opsForDesc = allRoutes.filter((r) => prefixMatchesPath(childPrefix, r.folderPath)); + out.push({ name, - description: ops.length ? buildFolderDescription(name, ops) : buildCreditsText(), + description: opsForDesc.length ? buildFolderDescription(name, opsForDesc) : buildCreditsText(), _pman: { folderManaged: true }, - item: items, + item: childItems, }); } - return root; + + return out; +} + +function buildFolderItemsFromGroups(groups: Map, allRoutes: FolderedRoute[]): PmItem[] { + const tree = buildFolderTree(groups); + return renderFolderTree(tree, allRoutes, []); } function prunePmanManagedItems(items: unknown[] | undefined): unknown[] { @@ -255,21 +438,18 @@ export function mergeOpenApiIntoPostmanCollection(args: { const existingByRouteId = indexExistingByRouteId(existing, routeKeyToRouteId); const generatedByKey = flattenGeneratedByRouteKey(generated); - const byFolder = new Map(); - const opsByFolder = new Map(); + const byFolder = new Map(); for (const op of routes) { const gen = generatedByKey.get(op.routeKey); if (!gen) continue; const prev = existingByRouteId.get(op.routeId); const merged = mergeRequest(prev, gen, op); - const list = byFolder.get(op.folder) ?? []; - list.push(merged); - byFolder.set(op.folder, list); - - const ops = opsByFolder.get(op.folder) ?? []; - ops.push(op); - opsByFolder.set(op.folder, ops); + const key = op.folderPath.join('>'); + const cur = byFolder.get(key) ?? { path: op.folderPath, items: [], ops: [] }; + cur.items.push(merged); + cur.ops.push(op); + byFolder.set(key, cur); } const out = deepClone(existing) as Record; @@ -281,27 +461,17 @@ export function mergeOpenApiIntoPostmanCollection(args: { } } const prevItems = Array.isArray(out.item) ? (out.item as unknown[]) : []; - const stateNames = Array.isArray(managedFoldersFromState) - ? new Set(managedFoldersFromState.map((x) => String(x))) - : null; - const withoutStateFolders = stateNames - ? prevItems.filter((raw) => { - if (!raw || typeof raw !== 'object') return true; - const it = raw as Record; - const name = typeof it.name === 'string' ? it.name : ''; - const isFolder = Array.isArray(it.item); - if (isFolder && name && stateNames.has(name)) return false; - return true; - }) - : prevItems; + const removalKeys = allFolderRemovalKeys(managedFoldersFromState, routes); + const withoutStateFolders = + removalKeys.length > 0 ? filterOutStateManagedFolderTrees(prevItems, removalKeys, []) : prevItems; const kept = prunePmanManagedItems(withoutStateFolders); - const fresh = buildFolderItems(byFolder, opsByFolder); + const fresh = buildFolderItemsFromGroups(byFolder, routes); out.item = [...fresh, ...kept]; return out; } export function managedFolderNames(routes: FolderedRoute[]): string[] { - return uniqueSorted(routes.map((r) => r.folder)); + return uniqueSorted(routes.map((r) => managedFolderStateKeyForPath(r.folderPath))); } export function shellCollection(name: string): Record { diff --git a/src/options.ts b/src/options.ts index 4eb60ad..f061c5f 100644 --- a/src/options.ts +++ b/src/options.ts @@ -2,6 +2,8 @@ import type { FastifyBaseLogger } from 'fastify'; export type FolderStrategy = 'path' | 'tags' | 'hybrid'; +export type PathFolderNesting = 'flat' | 'nested'; + export type FastifyPmanOptions = { workspaceId?: string; /** Optional Postman workspace URL. If set, the workspace id is extracted automatically. */ @@ -27,6 +29,8 @@ export type FastifyPmanOptions = { variableKey?: string; }; folderStrategy?: FolderStrategy; + /** When using `path` / `hybrid` path-based folders, choose single-level or nested subfolders. */ + pathFolderNesting?: PathFolderNesting; folderPathStripPrefix?: string; fetchImpl?: typeof fetch; }; @@ -40,6 +44,7 @@ export type ResolvedPmanOptions = { postmanBaseUrl: string | undefined; postmanApiBase: string; folderStrategy: FolderStrategy; + pathFolderNesting: PathFolderNesting; folderPathStripPrefix: string | undefined; reuseExistingCollectionByName: boolean; autoAuth: boolean; @@ -116,6 +121,7 @@ export function resolvePmanOptions(opts: FastifyPmanOptions): ResolvedPmanOption postmanBaseUrl: firstNonEmpty(opts.postmanBaseUrl, process.env.POSTMAN_BASE_URL), postmanApiBase: opts.postmanApiBase?.trim() || 'https://api.getpostman.com', folderStrategy: opts.folderStrategy ?? 'path', + pathFolderNesting: opts.pathFolderNesting === 'flat' || opts.pathFolderNesting === 'nested' ? opts.pathFolderNesting : 'nested', folderPathStripPrefix: opts.folderPathStripPrefix, reuseExistingCollectionByName: opts.reuseExistingCollectionByName ?? true, autoAuth: opts.autoAuth ?? true, diff --git a/src/sync.ts b/src/sync.ts index 98e5d47..52a59a0 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -33,7 +33,12 @@ export async function runPostmanSync(fastify: FastifyInstance, rt: PmanRuntime): const openApi = getOpenApiObject(fastify); const operations = listOpenApiOperations(openApi); - const foldered = attachFolders(operations, resolved.folderStrategy, resolved.folderPathStripPrefix); + const foldered = attachFolders( + operations, + resolved.folderStrategy, + resolved.folderPathStripPrefix, + resolved.pathFolderNesting, + ); const generated = await openApiToPostmanCollection(openApi); if (!resolved.postmanApiKey) { diff --git a/test/merge-collection.test.js b/test/merge-collection.test.js index b35e21a..7f5c121 100644 --- a/test/merge-collection.test.js +++ b/test/merge-collection.test.js @@ -45,6 +45,7 @@ test('merge preserves events and marks _pman', () => { name: undefined, summary: 'List users', folder: 'Users', + folderPath: ['Users'], }, ]; @@ -96,6 +97,7 @@ test('merge uses pman display name; summary leads docs', () => { name: 'Accept invite', summary: 'Accept organization invitation; invitee only', folder: 'Company', + folderPath: ['Company'], }, ]; @@ -104,3 +106,99 @@ test('merge uses pman display name; summary leads docs', () => { assert.match(flat, /"name":"Accept invite"/); assert.match(flat, /Accept organization invitation; invitee only/); }); + +test('path strategy nests folderPath into Postman subfolders (Auth / User / Admin)', () => { + const existing = shellCollection('API'); + const generated = shellCollection('gen'); + generated.item = [ + { + name: 'g', + item: [ + { + name: 'Create', + request: { + method: 'POST', + url: { path: ['auth', 'user', 'admin', 'create'] }, + }, + }, + ], + }, + ]; + + const routes = [ + { + routeId: 'adminCreate', + routeKey: 'POST /auth/user/admin/create', + method: 'POST', + path: '/auth/user/admin/create', + tags: [], + name: 'Create admin', + summary: 'Create admin user', + folder: 'Auth', + folderPath: ['Auth', 'User', 'Admin'], + }, + ]; + + const merged = mergeOpenApiIntoPostmanCollection({ existing, generated, routes }); + const flat = JSON.stringify(merged); + assert.match(flat, /"name":"Auth"/); + assert.match(flat, /"name":"User"/); + assert.match(flat, /"name":"Admin"/); + assert.match(flat, /"name":"Create admin"/); + assert.match(flat, /create admin user/i); +}); + +test('legacy tag-only folder is removed when state has only nested path keys (Demo>Users)', () => { + const existing = shellCollection('API'); + existing.item = [ + { + name: 'Users', + _pman: { folderManaged: true }, + item: [ + { + name: 'old', + request: { method: 'GET', url: { path: ['demo', 'users'] } }, + _pman: { routeId: 'listDemoUsers' }, + }, + ], + }, + ]; + const generated = shellCollection('gen'); + generated.item = [ + { + name: 'g', + item: [ + { + name: 'List', + request: { method: 'GET', url: { path: ['demo', 'users'] } }, + }, + ], + }, + ]; + const routes = [ + { + routeId: 'listDemoUsers', + routeKey: 'GET /demo/users', + method: 'GET', + path: '/demo/users', + tags: ['Users'], + name: 'List users', + summary: 'List demo users', + folder: 'Demo', + folderPath: ['Demo', 'Users'], + }, + ]; + const merged = mergeOpenApiIntoPostmanCollection({ + existing, + generated, + routes, + managedFoldersFromState: ['Demo>Users'], + }); + const top = merged.item; + assert.ok(Array.isArray(top)); + const rootLevelUsers = top.filter((x) => x && typeof x === 'object' && x.name === 'Users'); + assert.equal(rootLevelUsers.length, 0); + const flat = JSON.stringify(merged); + assert.match(flat, /"name":"Demo"/); + assert.match(flat, /listDemoUsers/); +});