diff --git a/README.md b/README.md index 1fc1996..3956dcb 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 @@ -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' }, }); @@ -80,13 +91,33 @@ If `x-pman-name` / `x-name` is omitted, the title falls back to `METHOD 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 83a5a3c..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: '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/); +});